diff --git a/src/App.tsx b/src/App.tsx
index 56f801190..681bb5993 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -169,7 +169,8 @@ export default function App() {
return (
{showNotes ? : content}
-
+ {/* The HUD mounts its own, placed clear of the bar (LaunchWindow). */}
+ {windowType !== "hud-overlay" && }
);
}
diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx
index f77d75d35..f1b9d2629 100644
--- a/src/components/launch/LaunchWindow.test.tsx
+++ b/src/components/launch/LaunchWindow.test.tsx
@@ -3,11 +3,18 @@ import "@testing-library/jest-dom";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StrictMode } from "react";
+import { toast } from "sonner";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nativeBridgeClient } from "@/native";
import type { NativePlatform } from "@/native/contracts";
import { TooltipProvider } from "../ui/tooltip";
-import { HUD_BAR_BOTTOM, HUD_POPOVER_GAP, HUD_POPOVER_MAX_HEIGHT } from "./hudGeometry";
+import {
+ HUD_BAR_BOTTOM,
+ HUD_POPOVER_GAP,
+ HUD_POPOVER_MAX_HEIGHT,
+ HUD_STACK_GAP,
+ HUD_TOAST_WIDTH,
+} from "./hudGeometry";
import { LaunchWindow } from "./LaunchWindow";
type SelectedSourceChangedListener = Parameters<
@@ -2206,3 +2213,137 @@ describe("LaunchWindow software encoder fallback notice", () => {
expect(recorderState.value.dismissSoftwareEncoderFallbackNotice).toHaveBeenCalledWith(true);
});
});
+
+// #1149: sonner's default corner of the HUD window is where the bar is, so a toast covered
+// Record, Pause and Stop, and the window being click-through there passed a click on its
+// close button to the app behind.
+describe("LaunchWindow toasts", () => {
+ const DISK_SPACE = "Not enough disk space to start recording (only 298 MB free).";
+
+ beforeEach(() => {
+ platformState.value = "darwin";
+ resetLaunchMocks();
+ resizeCallbacks.length = 0;
+ vi.stubGlobal("ResizeObserver", CapturingResizeObserver);
+ });
+
+ afterEach(() => {
+ act(() => {
+ toast.dismiss();
+ });
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ async function showToast() {
+ act(() => {
+ toast.error(DISK_SPACE);
+ });
+ const text = await screen.findByText(DISK_SPACE);
+ const item = text.closest("[data-sonner-toast]") as HTMLElement;
+ const list = text.closest("[data-sonner-toaster]") as HTMLElement;
+ return { item, list };
+ }
+
+ async function stubBar(width: number, height: number) {
+ const bar = (await screen.findByTestId("hud-drag-handle")).closest(
+ "[data-tray-layout]",
+ ) as HTMLElement;
+ stubBox(bar, width, height);
+ return bar;
+ }
+
+ it("centres the toasts above the bar instead of in the window's corner", async () => {
+ renderLaunchWindow();
+ await stubBar(400, 56);
+ await flushResizeObservers();
+
+ const { list } = await showToast();
+
+ expect(list).toHaveAttribute("data-y-position", "bottom");
+ expect(list).toHaveAttribute("data-x-position", "center");
+ expect(list.style.getPropertyValue("--offset-bottom")).toBe(
+ `${HUD_BAR_BOTTOM + 56 + HUD_STACK_GAP}px`,
+ );
+ });
+
+ it("moves the toasts above a popover opened over the bar", async () => {
+ renderLaunchWindow();
+ const bar = await stubBar(400, 56);
+ await flushResizeObservers();
+ const { list } = await showToast();
+
+ fireEvent.click(screen.getByRole("button", { name: "Language: English" }));
+ await screen.findByTestId("hud-language-menu");
+ const stackHeight = 56 + HUD_POPOVER_GAP + 300;
+ stubBox(bar.parentElement as HTMLElement, 400, stackHeight);
+ await flushResizeObservers();
+
+ expect(list.style.getPropertyValue("--offset-bottom")).toBe(
+ `${HUD_BAR_BOTTOM + stackHeight + HUD_STACK_GAP}px`,
+ );
+ });
+
+ it("marks a toast interactive, so the pushed cursor over it lifts click-through", async () => {
+ renderLaunchWindow();
+ await waitFor(() => {
+ expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenLastCalledWith(true);
+ });
+ const { item } = await showToast();
+
+ expect(item.closest("[data-hud-interactive='true']")).toBe(screen.getByTestId("hud-toaster"));
+
+ // jsdom has no hit test: the point resolves to the toast's close button, as it
+ // would in the browser.
+ const closeButton = item.querySelector("[data-close-button]");
+ expect(closeButton).not.toBeNull();
+ Object.defineProperty(document, "elementFromPoint", {
+ value: vi.fn(() => closeButton),
+ configurable: true,
+ });
+ const setIgnore = vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents);
+ try {
+ setIgnore.mockClear();
+ for (const listener of hudCursorListeners) listener(450, 300);
+ expect(setIgnore).toHaveBeenCalledWith(false);
+ } finally {
+ Reflect.deleteProperty(document, "elementFromPoint");
+ }
+ });
+
+ it("keeps the toasts in the rect the main process holds on screen", async () => {
+ renderLaunchWindow();
+ // A vertical bar, narrower than a toast.
+ await stubBar(48, 480);
+ await flushResizeObservers();
+ const { list } = await showToast();
+ // Sonner writes the front toast's height on its list once it has measured it; jsdom
+ // has no layout, so the measurement is written here.
+ list.style.setProperty("--front-toast-height", "64px");
+
+ const sizeCalls = vi.mocked(window.electronAPI.setHudOverlaySize).mock.calls;
+ const [allocatedWidth, allocatedHeight] = sizeCalls[sizeCalls.length - 1];
+ const height = 480 + HUD_STACK_GAP + 64;
+ await waitFor(() => {
+ expect(window.electronAPI.setHudOverlayContent).toHaveBeenLastCalledWith({
+ x: (allocatedWidth - HUD_TOAST_WIDTH) / 2,
+ y: allocatedHeight - HUD_BAR_BOTTOM - height,
+ width: HUD_TOAST_WIDTH,
+ height,
+ });
+ });
+
+ // And out of it once the toast is gone.
+ act(() => {
+ toast.dismiss();
+ });
+ await waitFor(() => {
+ expect(window.electronAPI.setHudOverlayContent).toHaveBeenLastCalledWith({
+ x: (allocatedWidth - 48) / 2,
+ y: allocatedHeight - HUD_BAR_BOTTOM - 480,
+ width: 48,
+ height: 480,
+ });
+ });
+ });
+});
diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx
index 4a310e07d..0aa3127b5 100644
--- a/src/components/launch/LaunchWindow.tsx
+++ b/src/components/launch/LaunchWindow.tsx
@@ -18,6 +18,7 @@ import { useRememberedSourceName } from "../../hooks/useRememberedSourceName";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import type { WebcamQualityId } from "../../hooks/webcamCaptureTarget";
import { requestCameraAccess } from "../../lib/requestCameraAccess";
+import { Toaster } from "../ui/sonner";
import {
HudCameraButton,
HudCursorButton,
@@ -40,9 +41,12 @@ import {
} from "./HudControls";
import { HudDeviceSettings, type HudDeviceSettingsLabels } from "./HudDeviceSettings";
import {
+ addHudToastsToStack,
computeHudBarMaxHeight,
computeHudModalMaxHeight,
computeHudPopoverMaxHeight,
+ computeHudToastBottom,
+ computeHudToastsHeight,
computeHudWindowSize,
HUD_BAR_BOTTOM,
HUD_GROWTH_RESERVE,
@@ -169,6 +173,11 @@ export function LaunchWindow() {
const hudBarRef = useRef(null);
const popoverRef = useRef(null);
const hudNoticesRef = useRef(null);
+ const hudToasterRef = useRef(null);
+ // Height of the toasts on screen (0 when none), and where they sit: see the toaster at the
+ // end of the render.
+ const hudToastsHeightRef = useRef(0);
+ const [hudToastBottom, setHudToastBottom] = useState(() => computeHudToastBottom(0));
// The camera list is enumerated from mount rather than on first open. It costs
// one enumerateDevices() call and means the picker renders its final content
@@ -463,13 +472,19 @@ export function LaunchWindow() {
// produce is computed exactly rather than measured a frame late.
const anchorRect = anchorEl?.getBoundingClientRect();
const stackRect = anchorRect?.width && anchorRect.height ? anchorRect : barRect;
- const stackWidth = stackRect.width || barWidth;
const stackHeight = stackRect.height || barHeight;
+ // Toasts sit above the stack, and they are just as visible: a toast pushed off the top
+ // of the screen is an error nobody reads.
+ setHudToastBottom(computeHudToastBottom(stackHeight));
+ const visible = addHudToastsToStack(
+ { width: stackRect.width || barWidth, height: stackHeight },
+ hudToastsHeightRef.current,
+ );
const contentFor = (size: { width: number; height: number }) => ({
- x: (size.width - stackWidth) / 2,
- y: size.height - HUD_BAR_BOTTOM - stackHeight,
- width: stackWidth,
- height: stackHeight,
+ x: (size.width - visible.width) / 2,
+ y: size.height - HUD_BAR_BOTTOM - visible.height,
+ width: visible.width,
+ height: visible.height,
});
// Computed from the allocation too, never read off stackRect: right after a
// resize the observer fires while the old viewport is still laid out, and a
@@ -519,6 +534,36 @@ export function LaunchWindow() {
};
}, [measureHudSize]);
+ // Toasts come and go inside sonner, which no ResizeObserver here sees: its toasts are
+ // absolutely positioned, so nothing around them changes size. What it does change is its
+ // own markup: a toast added or marked removed, and the front toast's height it measures
+ // and writes on the list. Watch that, and re-measure when the height they take changes.
+ useEffect(() => {
+ const toaster = hudToasterRef.current;
+ if (!toaster) return;
+ const onToastsChange = () => {
+ const list = toaster.querySelector("[data-sonner-toaster]");
+ const count = list
+ ? list.querySelectorAll("[data-sonner-toast]:not([data-removed='true'])").length
+ : 0;
+ const frontHeight = Number.parseFloat(
+ list?.style.getPropertyValue("--front-toast-height") ?? "",
+ );
+ const height = computeHudToastsHeight(frontHeight, count);
+ if (height === hudToastsHeightRef.current) return;
+ hudToastsHeightRef.current = height;
+ measureHudSize();
+ };
+ const observer = new MutationObserver(onToastsChange);
+ observer.observe(toaster, {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ attributeFilter: ["style", "data-removed"],
+ });
+ return () => observer.disconnect();
+ }, [measureHudSize]);
+
// Screen-derived caps follow the display the HUD is on: after a drag onto a
// shorter display, a tall vertical tray would otherwise overflow its work area.
useEffect(() => {
@@ -1404,6 +1449,19 @@ export function LaunchWindow() {
)}
+ {/* This window's toasts, and only this window mounts a toaster (App skips its own
+ for the HUD). Centred above the whole stack rather than in sonner's corner,
+ which is where the bar is (#1149). The wrapper marks them interactive: the
+ window is click-through everywhere else, so without it a toast's close button
+ passed the click to the app behind. It has no box of its own, the toasts are
+ `fixed`, so it adds nothing to the hit area but the toasts themselves. */}
+
+
+
);
}
diff --git a/src/components/launch/hudGeometry.test.ts b/src/components/launch/hudGeometry.test.ts
index 2ebb7c0ff..beca204da 100644
--- a/src/components/launch/hudGeometry.test.ts
+++ b/src/components/launch/hudGeometry.test.ts
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import {
+ addHudToastsToStack,
computeHudBarMaxHeight,
computeHudModalMaxHeight,
computeHudPopoverMaxHeight,
+ computeHudToastBottom,
+ computeHudToastsHeight,
computeHudTooltipClearance,
computeHudWindowSize,
HUD_BAR_BOTTOM,
@@ -11,7 +14,11 @@ import {
HUD_POPOVER_GAP,
HUD_POPOVER_MAX_HEIGHT,
HUD_POPOVER_MIN_HEIGHT,
+ HUD_STACK_GAP,
HUD_STACK_WIDTH,
+ HUD_TOAST_LIFT,
+ HUD_TOAST_VISIBLE,
+ HUD_TOAST_WIDTH,
} from "./hudGeometry";
const SCREEN = 1080;
@@ -132,3 +139,38 @@ describe("computeHudBarMaxHeight", () => {
);
});
});
+
+describe("HUD toasts", () => {
+ it("sit above the whole stack, not in the corner where the bar is (#1149)", () => {
+ // The bar alone, then the bar with a popover open over it.
+ expect(computeHudToastBottom(56)).toBe(HUD_BAR_BOTTOM + 56 + HUD_STACK_GAP);
+ expect(computeHudToastBottom(56 + HUD_POPOVER_GAP + 300)).toBe(
+ HUD_BAR_BOTTOM + 56 + HUD_POPOVER_GAP + 300 + HUD_STACK_GAP,
+ );
+ // A vertical bar is tall: the toasts clear all of it.
+ expect(computeHudToastBottom(480.4)).toBe(HUD_BAR_BOTTOM + 481 + HUD_STACK_GAP);
+ });
+
+ it("take the front toast's height plus one lift per toast behind it, up to sonner's three", () => {
+ expect(computeHudToastsHeight(0, 0)).toBe(0);
+ expect(computeHudToastsHeight(64, 0)).toBe(0);
+ // Not measured by sonner yet.
+ expect(computeHudToastsHeight(0, 2)).toBe(0);
+ expect(computeHudToastsHeight(Number.NaN, 2)).toBe(0);
+ expect(computeHudToastsHeight(63.5, 1)).toBe(64);
+ expect(computeHudToastsHeight(64, 2)).toBe(64 + HUD_TOAST_LIFT);
+ expect(computeHudToastsHeight(64, 7)).toBe(64 + (HUD_TOAST_VISIBLE - 1) * HUD_TOAST_LIFT);
+ });
+
+ it("count in the rect kept on screen, as wide as a toast over a narrow vertical bar", () => {
+ expect(addHudToastsToStack({ width: 48, height: 480 }, 0)).toEqual({ width: 48, height: 480 });
+ expect(addHudToastsToStack({ width: 48, height: 480 }, 64)).toEqual({
+ width: HUD_TOAST_WIDTH,
+ height: 480 + HUD_STACK_GAP + 64,
+ });
+ expect(addHudToastsToStack({ width: 600, height: 56 }, 64)).toEqual({
+ width: 600,
+ height: 56 + HUD_STACK_GAP + 64,
+ });
+ });
+});
diff --git a/src/components/launch/hudGeometry.ts b/src/components/launch/hudGeometry.ts
index ae0787225..da93d456b 100644
--- a/src/components/launch/hudGeometry.ts
+++ b/src/components/launch/hudGeometry.ts
@@ -150,6 +150,47 @@ export function computeHudWindowSize(input: HudSizeInput): {
};
}
+/** Sonner's toast width (its TOAST_WIDTH), which a centred toast takes whatever it says. */
+export const HUD_TOAST_WIDTH = 356;
+/** How far sonner lifts each toast behind the front one in its collapsed stack (its GAP). */
+export const HUD_TOAST_LIFT = 14;
+/** How many toasts sonner shows at once (its VISIBLE_TOASTS_AMOUNT). */
+export const HUD_TOAST_VISIBLE = 3;
+
+/**
+ * Where the HUD's toasts sit: their bottom edge this far above the window's bottom edge,
+ * which is above the whole stack (the bar and anything open over it).
+ *
+ * Sonner's default corner of the window is where the bar is, so a toast covered Record,
+ * Pause and Stop, and since sonner holds a toast while the pointer is over it, it stayed as
+ * long as the user was reaching for the controls under it (#1149). Above the stack is the
+ * reserve the window keeps for floating surfaces anyway, in either orientation.
+ */
+export function computeHudToastBottom(stackHeight: number): number {
+ return HUD_BAR_BOTTOM + Math.ceil(stackHeight) + HUD_STACK_GAP;
+}
+
+/**
+ * Height of sonner's collapsed stack: the front toast, plus one lift per toast behind it.
+ * Zero when nothing is up, or before sonner has measured the front toast.
+ */
+export function computeHudToastsHeight(frontHeight: number, count: number): number {
+ if (count <= 0 || !(frontHeight > 0)) return 0;
+ return Math.ceil(frontHeight) + (Math.min(count, HUD_TOAST_VISIBLE) - 1) * HUD_TOAST_LIFT;
+}
+
+/**
+ * The stack plus the toasts floating above it: what has to stay on screen. Both are centred
+ * on the window, so only the width and the height grow.
+ */
+export function addHudToastsToStack(stack: HudSize, toastsHeight: number): HudSize {
+ if (toastsHeight <= 0) return stack;
+ return {
+ width: Math.max(stack.width, HUD_TOAST_WIDTH),
+ height: stack.height + HUD_STACK_GAP + Math.ceil(toastsHeight),
+ };
+}
+
/**
* How far past its trigger a tooltip has to sit to clear the BAR, on the side it opens.
*