From 936f30a4ec1eb5ab7a26e801461d49d652cced48 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 26 Jun 2026 09:15:54 +0200 Subject: [PATCH 1/3] fix(launch): resize HUD overlay to fit system language prompt The system-language suggestion prompt is anchored at fixed top-8 of the renderer, but measureHudSize only sized the OS overlay to fit the bottom HUD bar. On a non-English OS that triggered the prompt for the first time, the window was too short and the prompt's buttons were clipped above the visible area, making the app appear unusable (issue #30). Include the prompt in the size measurement and observe it via the existing ResizeObserver so the bottom-anchored window grows upward to contain the prompt's full extent. Add a test asserting the overlay height covers the prompt plus top margin. --- src/components/launch/LaunchWindow.test.tsx | 110 ++++++++++++++++++-- src/components/launch/LaunchWindow.tsx | 19 ++++ 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 29a67374b..bd8bfb62d 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -78,20 +78,24 @@ vi.mock("@/native", () => ({ }, })); +const i18nState = vi.hoisted(() => ({ + value: { + locale: "en", + setLocale: vi.fn(), + systemLocaleSuggestion: null as string | null, + acceptSystemLocaleSuggestion: vi.fn(), + dismissSystemLocaleSuggestion: vi.fn(), + resolveSystemLocaleSuggestion: vi.fn(), + }, +})); + vi.mock("@/i18n/loader", () => ({ getAvailableLocales: () => ["en"], getLocaleName: () => "English", })); vi.mock("@/contexts/I18nContext", () => ({ - useI18n: () => ({ - locale: "en", - setLocale: vi.fn(), - systemLocaleSuggestion: null, - acceptSystemLocaleSuggestion: vi.fn(), - dismissSystemLocaleSuggestion: vi.fn(), - resolveSystemLocaleSuggestion: vi.fn(), - }), + useI18n: () => i18nState.value, useScopedT: () => (key: string) => { const translations: Record = { "sourceSelector.defaultSourceName": "Screen", @@ -115,6 +119,11 @@ vi.mock("@/contexts/I18nContext", () => ({ "tooltips.hideHUD": "Hide HUD", "tooltips.closeApp": "Close App", language: "Language", + "systemLanguagePrompt.title": "Use your system language?", + "systemLanguagePrompt.description": + "We detected English as your system language. Do you want to switch OpenScreen to English?", + "systemLanguagePrompt.keepDefault": "Keep current language", + "systemLanguagePrompt.switch": "Switch to English", }; return translations[key] ?? key; }, @@ -208,6 +217,10 @@ describe("LaunchWindow record button", () => { recorderState.value.toggleRecording.mockClear(); selectedSourceChangedListeners = []; sourceSelectorClosedListeners = []; + i18nState.value.systemLocaleSuggestion = null; + i18nState.value.acceptSystemLocaleSuggestion.mockClear(); + i18nState.value.dismissSystemLocaleSuggestion.mockClear(); + i18nState.value.resolveSystemLocaleSuggestion.mockClear(); stubElectronAPI(vi.fn(async () => null)); }); @@ -341,3 +354,84 @@ describe("LaunchWindow record button", () => { }); }); }); + +describe("LaunchWindow system language prompt", () => { + beforeEach(() => { + platformState.value = "darwin"; + class ResizeObserver { + observe() { + return undefined; + } + unobserve() { + return undefined; + } + disconnect() { + return undefined; + } + } + vi.stubGlobal("ResizeObserver", ResizeObserver); + recorderState.value.toggleRecording.mockClear(); + selectedSourceChangedListeners = []; + sourceSelectorClosedListeners = []; + i18nState.value.systemLocaleSuggestion = null; + i18nState.value.acceptSystemLocaleSuggestion.mockClear(); + i18nState.value.dismissSystemLocaleSuggestion.mockClear(); + i18nState.value.resolveSystemLocaleSuggestion.mockClear(); + stubElectronAPI(vi.fn(async () => null)); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("grows the HUD overlay tall enough to fit the prompt so its buttons stay clickable", async () => { + i18nState.value.systemLocaleSuggestion = "zh-CN"; + + renderLaunchWindow(); + + const prompt = await screen.findByText("Use your system language?"); + expect(prompt).toBeInTheDocument(); + + // jsdom reports zero layout, so stub the prompt's box: 32px top offset + // (the panel's `top-8`) plus a realistic height forces the resize path + // to compute a window that contains the prompt's full extent. + const promptBox = { width: 480, height: 130 }; + vi.spyOn(prompt.parentElement as HTMLElement, "getBoundingClientRect").mockReturnValue({ + top: 32, + left: 60, + right: 60 + promptBox.width, + bottom: 32 + promptBox.height, + width: promptBox.width, + height: promptBox.height, + x: 60, + y: 32, + toJSON: () => ({}), + }); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled(); + }); + + const sizeMock = window.electronAPI.setHudOverlaySize as unknown as { + mock: { calls: Array<[number, number]> }; + }; + const [, height] = sizeMock.mock.calls[sizeMock.mock.calls.length - 1]; + // Must at least cover the prompt (32 + 130) plus the TOP_MARGIN slack (24). + expect(height).toBeGreaterThanOrEqual(32 + promptBox.height + 24); + }); + + it("routes the switch and keep-default buttons to the i18n context", async () => { + i18nState.value.systemLocaleSuggestion = "zh-CN"; + + renderLaunchWindow(); + + await screen.findByText("Use your system language?"); + + fireEvent.click(screen.getByRole("button", { name: "Switch to English" })); + expect(i18nState.value.acceptSystemLocaleSuggestion).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Keep current language" })); + expect(i18nState.value.dismissSystemLocaleSuggestion).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 6b614caae..e01c2964a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -148,6 +148,7 @@ export function LaunchWindow() { const languageMenuPanelRef = useRef(null); const hudBarRef = useRef(null); const deviceSelectorRef = useRef(null); + const systemLocalePromptRef = useRef(null); // Measured bar height, anchors the popups above the tall vertical tray so they don't overlap it. const [hudBarHeight, setHudBarHeight] = useState(0); const [languageMenuStyle, setLanguageMenuStyle] = useState<{ @@ -351,6 +352,19 @@ export function LaunchWindow() { halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); } + // The system-language prompt is anchored at `top-8` (32px) of the renderer; the + // bottom-anchored overlay only reserves enough room for the bar, so without this + // the prompt's buttons get clipped above the OS window's visible area (issue #30). + if (systemLocalePromptRef.current) { + const rect = systemLocalePromptRef.current.getBoundingClientRect(); + // scrollHeight covers the un-clipped box if the viewport is currently too short. + const promptHeight = rect.height || systemLocalePromptRef.current.scrollHeight; + if (promptHeight > 0) { + topFromBottom = Math.max(topFromBottom, 32 + promptHeight); + } + halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); + } + setHudBarHeight((prev) => { const next = Math.round(barEl.scrollHeight); return Math.abs(prev - next) > 1 ? next : prev; @@ -402,6 +416,10 @@ export function LaunchWindow() { (el: HTMLDivElement | null) => observeHudElement(el, languageMenuPanelRef), [observeHudElement], ); + const setSystemLocalePromptEl = useCallback( + (el: HTMLDivElement | null) => observeHudElement(el, systemLocalePromptRef), + [observeHudElement], + ); const hudIgnoreMouseEventsRef = useRef(undefined); const setHudMouseEventsEnabled = useCallback( @@ -621,6 +639,7 @@ export function LaunchWindow() { > {systemLocaleSuggestion && (
From a468f061b3322abfe4551653ff69bee4bdfe7399 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 26 Jun 2026 09:34:24 +0200 Subject: [PATCH 2/3] fix(launch): backfill prompt observer and make sizing test deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the CodeRabbit review on #37: * measureHudSize's ResizeObserver effect only observed hudBarRef and deviceSelectorRef. If the system-language prompt (or language menu) mounted before the effect ran, their reflows would never trigger another resize. Backfill both refs in the effect. * The size-assertion test was stubbing getBoundingClientRect after render but never re-fired the observer, so it read the mount-time call (with height: 0 from jsdom) and passed for the wrong reason. Capture the ResizeObserver callback, fire it after stubbing, and also stub the bar's box to mimic a real layout — verified the test now fails (height 68 vs expected 186) when the prompt-sizing path is disabled. --- src/components/launch/LaunchWindow.test.tsx | 50 +++++++++++++++++++-- src/components/launch/LaunchWindow.tsx | 5 +++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index bd8bfb62d..b353b5c77 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -9,6 +9,7 @@ type SelectedSourceChangedListener = Parameters< >[0]; const platformState = vi.hoisted(() => ({ value: "darwin" })); +const resizeCallbacks = vi.hoisted(() => [] as Array); const recorderState = vi.hoisted(() => ({ value: { @@ -358,7 +359,11 @@ describe("LaunchWindow record button", () => { describe("LaunchWindow system language prompt", () => { beforeEach(() => { platformState.value = "darwin"; + resizeCallbacks.length = 0; class ResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.push(callback); + } observe() { return undefined; } @@ -393,11 +398,36 @@ describe("LaunchWindow system language prompt", () => { const prompt = await screen.findByText("Use your system language?"); expect(prompt).toBeInTheDocument(); - // jsdom reports zero layout, so stub the prompt's box: 32px top offset - // (the panel's `top-8`) plus a realistic height forces the resize path - // to compute a window that contains the prompt's full extent. + // jsdom reports zero layout, so stub both the bar and the prompt to mimic a + // real HUD: a 56px bar near the bottom of an 800px viewport, and a 130px prompt + // at top-8. Without the prompt-sizing path, the height collapses to the bar + // (~80px) and this assertion would fail. + const viewportHeight = 800; + const barHeight = 56; + const bottomMargin = 20; + const barBottom = viewportHeight - bottomMargin; + const bar = prompt.parentElement?.parentElement?.querySelector( + "[data-tray-layout]", + ) as HTMLElement | null; + if (bar) { + vi.spyOn(bar, "getBoundingClientRect").mockReturnValue({ + top: barBottom - barHeight, + left: 200, + right: 600, + bottom: barBottom, + width: 400, + height: barHeight, + x: 200, + y: barBottom - barHeight, + toJSON: () => ({}), + }); + Object.defineProperty(bar, "scrollHeight", { value: barHeight, configurable: true }); + Object.defineProperty(bar, "scrollWidth", { value: 400, configurable: true }); + } + const promptBox = { width: 480, height: 130 }; - vi.spyOn(prompt.parentElement as HTMLElement, "getBoundingClientRect").mockReturnValue({ + const promptPanel = prompt.parentElement as HTMLElement; + vi.spyOn(promptPanel, "getBoundingClientRect").mockReturnValue({ top: 32, left: 60, right: 60 + promptBox.width, @@ -409,6 +439,14 @@ describe("LaunchWindow system language prompt", () => { toJSON: () => ({}), }); + // Fire any observers attached during render so the spied rect is actually + // consumed by measureHudSize; the mount-time call had rect.height === 0. + await act(async () => { + for (const callback of resizeCallbacks) { + callback([], {} as ResizeObserver); + } + }); + await waitFor(() => { expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled(); }); @@ -419,6 +457,10 @@ describe("LaunchWindow system language prompt", () => { const [, height] = sizeMock.mock.calls[sizeMock.mock.calls.length - 1]; // Must at least cover the prompt (32 + 130) plus the TOP_MARGIN slack (24). expect(height).toBeGreaterThanOrEqual(32 + promptBox.height + 24); + // And must be less than the full viewport (the bar is the lower bound, not + // the viewport) — guards against regressions that always grow to the full + // viewport because of a missed bottom anchor. + expect(height).toBeLessThan(viewportHeight + 24); }); it("routes the switch and keep-default buttons to the i18n context", async () => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index e01c2964a..21f06e5a5 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -387,6 +387,11 @@ export function LaunchWindow() { hudResizeObserverRef.current = observer; if (hudBarRef.current) observer.observe(hudBarRef.current); if (deviceSelectorRef.current) observer.observe(deviceSelectorRef.current); + // Backfill refs that may have been set during the commit phase, before this + // effect created the observer (e.g. the system-language prompt or the language + // menu). Without this, their reflows wouldn't trigger another resize. + if (systemLocalePromptRef.current) observer.observe(systemLocalePromptRef.current); + if (languageMenuPanelRef.current) observer.observe(languageMenuPanelRef.current); measureHudSize(); return () => { observer.disconnect(); From aa6696124cc431a41b1db73759ff5a7dddd7d603 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sun, 28 Jun 2026 19:47:00 +0200 Subject: [PATCH 3/3] refactor(launch): drop hardcoded top-8 and trim prompt test bloat - Use rect.top + promptHeight in measureHudSize so the prompt's measured bottom drives overlay height instead of a magic 32 that would silently drift from the Tailwind class. - Hoist StubResizeObserver / CapturingResizeObserver to module scope and factor resetLaunchMocks() so the two describes share setup. Drop the trivial button-routing test (the click handlers are direct i18n callbacks in the JSX). --- src/components/launch/LaunchWindow.test.tsx | 106 +++++++------------- src/components/launch/LaunchWindow.tsx | 11 +- 2 files changed, 42 insertions(+), 75 deletions(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index b353b5c77..bfdddad26 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -11,6 +11,25 @@ type SelectedSourceChangedListener = Parameters< const platformState = vi.hoisted(() => ({ value: "darwin" })); const resizeCallbacks = vi.hoisted(() => [] as Array); +class StubResizeObserver { + observe() { + return undefined; + } + unobserve() { + return undefined; + } + disconnect() { + return undefined; + } +} + +class CapturingResizeObserver extends StubResizeObserver { + constructor(callback: ResizeObserverCallback) { + super(); + resizeCallbacks.push(callback); + } +} + const recorderState = vi.hoisted(() => ({ value: { recording: false, @@ -200,29 +219,22 @@ function emitSourceSelectorClosed() { }); } +function resetLaunchMocks() { + vi.stubGlobal("ResizeObserver", StubResizeObserver); + recorderState.value.toggleRecording.mockClear(); + selectedSourceChangedListeners = []; + sourceSelectorClosedListeners = []; + i18nState.value.systemLocaleSuggestion = null; + i18nState.value.acceptSystemLocaleSuggestion.mockClear(); + i18nState.value.dismissSystemLocaleSuggestion.mockClear(); + i18nState.value.resolveSystemLocaleSuggestion.mockClear(); + stubElectronAPI(vi.fn(async () => null)); +} + describe("LaunchWindow record button", () => { beforeEach(() => { platformState.value = "darwin"; - class ResizeObserver { - observe() { - return undefined; - } - unobserve() { - return undefined; - } - disconnect() { - return undefined; - } - } - vi.stubGlobal("ResizeObserver", ResizeObserver); - recorderState.value.toggleRecording.mockClear(); - selectedSourceChangedListeners = []; - sourceSelectorClosedListeners = []; - i18nState.value.systemLocaleSuggestion = null; - i18nState.value.acceptSystemLocaleSuggestion.mockClear(); - i18nState.value.dismissSystemLocaleSuggestion.mockClear(); - i18nState.value.resolveSystemLocaleSuggestion.mockClear(); - stubElectronAPI(vi.fn(async () => null)); + resetLaunchMocks(); }); afterEach(() => { @@ -359,30 +371,9 @@ describe("LaunchWindow record button", () => { describe("LaunchWindow system language prompt", () => { beforeEach(() => { platformState.value = "darwin"; + resetLaunchMocks(); resizeCallbacks.length = 0; - class ResizeObserver { - constructor(callback: ResizeObserverCallback) { - resizeCallbacks.push(callback); - } - observe() { - return undefined; - } - unobserve() { - return undefined; - } - disconnect() { - return undefined; - } - } - vi.stubGlobal("ResizeObserver", ResizeObserver); - recorderState.value.toggleRecording.mockClear(); - selectedSourceChangedListeners = []; - sourceSelectorClosedListeners = []; - i18nState.value.systemLocaleSuggestion = null; - i18nState.value.acceptSystemLocaleSuggestion.mockClear(); - i18nState.value.dismissSystemLocaleSuggestion.mockClear(); - i18nState.value.resolveSystemLocaleSuggestion.mockClear(); - stubElectronAPI(vi.fn(async () => null)); + vi.stubGlobal("ResizeObserver", CapturingResizeObserver); }); afterEach(() => { @@ -398,10 +389,7 @@ describe("LaunchWindow system language prompt", () => { const prompt = await screen.findByText("Use your system language?"); expect(prompt).toBeInTheDocument(); - // jsdom reports zero layout, so stub both the bar and the prompt to mimic a - // real HUD: a 56px bar near the bottom of an 800px viewport, and a 130px prompt - // at top-8. Without the prompt-sizing path, the height collapses to the bar - // (~80px) and this assertion would fail. + // jsdom reports zero layout, so stub both the bar and the prompt to mimic a real HUD. const viewportHeight = 800; const barHeight = 56; const bottomMargin = 20; @@ -439,8 +427,7 @@ describe("LaunchWindow system language prompt", () => { toJSON: () => ({}), }); - // Fire any observers attached during render so the spied rect is actually - // consumed by measureHudSize; the mount-time call had rect.height === 0. + // Fire any observers attached during render so the spied rect is actually consumed. await act(async () => { for (const callback of resizeCallbacks) { callback([], {} as ResizeObserver); @@ -455,25 +442,10 @@ describe("LaunchWindow system language prompt", () => { mock: { calls: Array<[number, number]> }; }; const [, height] = sizeMock.mock.calls[sizeMock.mock.calls.length - 1]; - // Must at least cover the prompt (32 + 130) plus the TOP_MARGIN slack (24). + // Must at least cover the prompt plus the TOP_MARGIN slack (24). expect(height).toBeGreaterThanOrEqual(32 + promptBox.height + 24); - // And must be less than the full viewport (the bar is the lower bound, not - // the viewport) — guards against regressions that always grow to the full - // viewport because of a missed bottom anchor. + // And must be less than the full viewport — guards against regressions that always + // grow to the full viewport because of a missed bottom anchor. expect(height).toBeLessThan(viewportHeight + 24); }); - - it("routes the switch and keep-default buttons to the i18n context", async () => { - i18nState.value.systemLocaleSuggestion = "zh-CN"; - - renderLaunchWindow(); - - await screen.findByText("Use your system language?"); - - fireEvent.click(screen.getByRole("button", { name: "Switch to English" })); - expect(i18nState.value.acceptSystemLocaleSuggestion).toHaveBeenCalledTimes(1); - - fireEvent.click(screen.getByRole("button", { name: "Keep current language" })); - expect(i18nState.value.dismissSystemLocaleSuggestion).toHaveBeenCalledTimes(1); - }); }); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 21f06e5a5..24d273b49 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -352,15 +352,12 @@ export function LaunchWindow() { halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); } - // The system-language prompt is anchored at `top-8` (32px) of the renderer; the - // bottom-anchored overlay only reserves enough room for the bar, so without this - // the prompt's buttons get clipped above the OS window's visible area (issue #30). + // Prompt sits at `fixed top-8`; grow the window to fit it so its buttons don't clip (issue #30). if (systemLocalePromptRef.current) { const rect = systemLocalePromptRef.current.getBoundingClientRect(); - // scrollHeight covers the un-clipped box if the viewport is currently too short. const promptHeight = rect.height || systemLocalePromptRef.current.scrollHeight; if (promptHeight > 0) { - topFromBottom = Math.max(topFromBottom, 32 + promptHeight); + topFromBottom = Math.max(topFromBottom, rect.top + promptHeight); } halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); } @@ -387,9 +384,7 @@ export function LaunchWindow() { hudResizeObserverRef.current = observer; if (hudBarRef.current) observer.observe(hudBarRef.current); if (deviceSelectorRef.current) observer.observe(deviceSelectorRef.current); - // Backfill refs that may have been set during the commit phase, before this - // effect created the observer (e.g. the system-language prompt or the language - // menu). Without this, their reflows wouldn't trigger another resize. + // Backfill refs set before the observer existed (e.g. the prompt or language menu). if (systemLocalePromptRef.current) observer.observe(systemLocalePromptRef.current); if (languageMenuPanelRef.current) observer.observe(languageMenuPanelRef.current); measureHudSize();