From af8174c291be1776e5491d80304f57b7279f6713 Mon Sep 17 00:00:00 2001 From: Chun Fang Date: Tue, 11 Aug 2026 00:08:05 +0000 Subject: [PATCH 1/5] feat(overview): fit the matrix on one screen and present it full screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matrix overflowed a laptop viewport (1188px against ~900px of usable height). Row padding tightens, the AgentX scenario name is abbreviated in the row header with the full name kept as the accessible name, and both comparison modes can now hide uninteresting rows — the 30-day view keeps its existing control, the hardware views gain one for rows with no result on any platform. Separate URL keys (rows=, hwrows=) let each mode remember its own answer across a tab switch; both default to showing everything. Present mode hands the matrix to the Fullscreen API and scales it with one CSS zoom, so the projection cannot drift from the page layout. While presenting, the control strip carries the SLO, the view tabs, and the filters beside Exit as chips that name the click and flip with the scope. Arrow keys page between views. 中文:本PR解决此前overview下不能一屏放下所有信息的局限。方法:全屏显示 --- .../app/src/app/(dashboard)/overview/page.tsx | 4 + .../app/src/app/api/v1/overview/route.test.ts | 20 +- packages/app/src/app/api/v1/overview/route.ts | 4 + .../src/app/zh/(dashboard)/overview/page.tsx | 4 + .../components/overview/overview-nav-link.tsx | 2 +- .../overview/overview-navigation.test.tsx | 4 + .../src/components/overview/overview-page.tsx | 360 +++++++++++++----- .../overview/overview-presentation.test.tsx | 171 +++++++++ .../overview/overview-presentation.tsx | 218 +++++++++++ .../overview/overview-reference-select.tsx | 8 +- .../overview/overview-scorecard.tsx | 317 ++++++++++++++- packages/app/src/lib/overview-data.server.ts | 48 ++- packages/app/src/lib/overview-data.test.ts | 184 +++++++++ packages/app/src/lib/overview-data.ts | 112 ++++++ packages/app/src/lib/overview-links.test.ts | 110 ++++++ packages/app/src/lib/overview-links.ts | 56 ++- 16 files changed, 1488 insertions(+), 134 deletions(-) create mode 100644 packages/app/src/components/overview/overview-presentation.test.tsx create mode 100644 packages/app/src/components/overview/overview-presentation.tsx diff --git a/packages/app/src/app/(dashboard)/overview/page.tsx b/packages/app/src/app/(dashboard)/overview/page.tsx index a0c044466..ae76ab5a4 100644 --- a/packages/app/src/app/(dashboard)/overview/page.tsx +++ b/packages/app/src/app/(dashboard)/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/api/v1/overview/route.test.ts b/packages/app/src/app/api/v1/overview/route.test.ts index f0415cf37..327905576 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 ba6819160..3842b87c7 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/zh/(dashboard)/overview/page.tsx b/packages/app/src/app/zh/(dashboard)/overview/page.tsx index 524d15db1..742dcc863 100644 --- a/packages/app/src/app/zh/(dashboard)/overview/page.tsx +++ b/packages/app/src/app/zh/(dashboard)/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 c0d282036..906938c31 100644 --- a/packages/app/src/components/overview/overview-nav-link.tsx +++ b/packages/app/src/components/overview/overview-nav-link.tsx @@ -8,7 +8,7 @@ import type { OverviewSearchKey } from '@/lib/overview-links'; import { useOverviewNavigation } from './overview-navigation'; 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 e4b4e64c4..3cb1f3187 100644 --- a/packages/app/src/components/overview/overview-navigation.test.tsx +++ b/packages/app/src/components/overview/overview-navigation.test.tsx @@ -30,6 +30,10 @@ function pageData(tier: OverviewTier): OverviewPageData { comparisonMode: 'hardware', referenceHardware: 'b200', modelScope: 'default', + rowScope: 'all', + hardwareRowScope: 'all', + unchangedRowCount: 0, + emptyRowCount: 0, historicalWindow: null, }; } diff --git a/packages/app/src/components/overview/overview-page.tsx b/packages/app/src/components/overview/overview-page.tsx index c5764c5dc..5abe37473 100644 --- a/packages/app/src/components/overview/overview-page.tsx +++ b/packages/app/src/components/overview/overview-page.tsx @@ -10,14 +10,22 @@ import { MobileOverviewList, OverviewComparisonSwitcher, OverviewEngineScopeSwitcher, + OverviewHardwareRowScopeToggle, OverviewMethodology, OverviewModelScopeToggle, + OverviewRowScopeToggle, OverviewTierSwitcher, overviewFormatters, OVERVIEW_STRINGS, type OverviewLocale, } from './overview-scorecard'; import { OverviewNavigationProvider, useOverviewNavigation } from './overview-navigation'; +import { + OverviewPresentationProvider, + OverviewPresentationSurface, + OverviewPresentToggle, + useOverviewPresentation, +} from './overview-presentation'; /** The SemiAnalysis AI Cloud TCO model behind `HW_REGISTRY.costh`. */ const OVERVIEW_SOURCE_HREF = 'https://semianalysis.com/ai-cloud-tco-model/'; @@ -38,134 +46,294 @@ export function OverviewPageContent({ data, locale }: OverviewPageProps) { data.comparisonMode, data.referenceHardware, data.modelScope, + data.rowScope, + data.hardwareRowScope, )} > - + + + ); } function OverviewPageBody({ locale }: { locale: OverviewLocale }) { const { data } = useOverviewNavigation(); + 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. */} - - + ); + + 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' ? ( + + ) : ( + + )} + +
+
+ ); +} + +/** The half of the page that goes fullscreen: the view tabs and the matrix. */ +function OverviewMatrixSection({ locale }: { locale: OverviewLocale }) { + const { data } = useOverviewNavigation(); + const { presenting } = useOverviewPresentation(); + const strings = OVERVIEW_STRINGS[locale]; + const formatters = overviewFormatters(locale); + + 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. */} + + + {/* Presenting fixes the layout width at the desktop breakpoint, so the + phone list would only ever be dead weight behind the matrix. */} + {presenting ? null : ( + + )} + {presenting ? 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 000000000..e3f79546f --- /dev/null +++ b/packages/app/src/components/overview/overview-presentation.test.tsx @@ -0,0 +1,171 @@ +// @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 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(); + }); +}); 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 000000000..eb795a3be --- /dev/null +++ b/packages/app/src/components/overview/overview-presentation.tsx @@ -0,0 +1,218 @@ +'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 { useOverviewNavigation } 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, 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, + data.referenceHardware, + data.modelScope, + ), + ['compare'], + ); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [presenting, data, 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 24d26d866..396b8a0e5 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 } from './overview-navigation'; +import { useIsPresenting } from './overview-presentation'; interface ReferenceOption { href: string; @@ -28,6 +29,11 @@ export function OverviewReferenceSelect({ value: OverviewReferenceHardware; }) { const navigation = useOverviewNavigation(); + // 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 (