From 8b99cee9c51881fbd4a95be9b64f42cea81411ea Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 9 Jun 2026 20:04:43 +0000 Subject: [PATCH] fix(ui): trigger sidebar peek on iPad Safari with a trackpad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iPadOS Safari never flips the `(hover: hover) and (pointer: fine)` media query when a trackpad/mouse is attached, so `pointerCanPeek` stayed false and the collapsed-sidebar hover peek never opened even though a real cursor was driving the UI (PAP-10725). Detect a fine pointer at runtime: a genuine mouse/trackpad emits pointer events with `pointerType: "mouse"` (touch → "touch", Pencil → "pen"), so listening for the first such event unlocks peek without false-positives on touch-only input. Peek capability is a one-way latch for the session. Co-Authored-By: Claude Opus 4.8 --- ui/src/context/SidebarContext.test.tsx | 36 ++++++++++++++++++++++++++ ui/src/context/SidebarContext.tsx | 27 ++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/ui/src/context/SidebarContext.test.tsx b/ui/src/context/SidebarContext.test.tsx index ac0412dbd00..a25b18ee832 100644 --- a/ui/src/context/SidebarContext.test.tsx +++ b/ui/src/context/SidebarContext.test.tsx @@ -248,6 +248,42 @@ describe("SidebarContext", () => { act(() => capturedValue?.setPeeking(true)); expect(capturedValue?.peeking).toBe(false); }); + + // iPadOS Safari keeps the hover/pointer media query false even with a + // trackpad attached (PAP-10725); a real cursor still emits "mouse" pointer + // events, which must unlock peek at runtime. + it("peeks once a mouse-type pointer event is seen (iPad + trackpad)", () => { + localStorage.setItem(COLLAPSED_STORAGE_KEY, "1"); + setViewport({ mobile: false, hoverFine: false }); + active = renderProvider(); + expect(capturedValue?.collapsed).toBe(true); + + // No cursor seen yet → peek stays gated despite the media query. + act(() => capturedValue?.setPeeking(true)); + expect(capturedValue?.peeking).toBe(false); + + // A trackpad/mouse moves: a "mouse" pointer event unlocks peek. + act(() => { + const e = new Event("pointermove"); + (e as unknown as { pointerType: string }).pointerType = "mouse"; + window.dispatchEvent(e); + }); + expect(capturedValue?.peeking).toBe(true); + }); + + it("ignores touch pointer events (touch-only stays gated)", () => { + localStorage.setItem(COLLAPSED_STORAGE_KEY, "1"); + setViewport({ mobile: false, hoverFine: false }); + active = renderProvider(); + + act(() => capturedValue?.setPeeking(true)); + act(() => { + const e = new Event("pointerover"); + (e as unknown as { pointerType: string }).pointerType = "touch"; + window.dispatchEvent(e); + }); + expect(capturedValue?.peeking).toBe(false); + }); }); describe("back-compat", () => { diff --git a/ui/src/context/SidebarContext.tsx b/ui/src/context/SidebarContext.tsx index 31facd62e7a..d24c065b32c 100644 --- a/ui/src/context/SidebarContext.tsx +++ b/ui/src/context/SidebarContext.tsx @@ -112,11 +112,36 @@ export function SidebarProvider({ children }: { children: ReactNode }) { useEffect(() => { if (typeof window.matchMedia !== "function") return; const mql = window.matchMedia(PEEK_POINTER_QUERY); - const onChange = (e: MediaQueryListEvent) => setPointerCanPeek(e.matches); + // Latch on only — see the runtime detection below for why this never flips + // back to false. + const onChange = (e: MediaQueryListEvent) => { + if (e.matches) setPointerCanPeek(true); + }; mql.addEventListener("change", onChange); return () => mql.removeEventListener("change", onChange); }, []); + // iPadOS Safari does not flip the `(hover: hover) and (pointer: fine)` media + // query when a trackpad/mouse is attached, so the query above stays false even + // though a real cursor is driving the UI — and hover-peek never triggers + // (PAP-10725). Detect a fine pointer at runtime instead: a genuine + // mouse/trackpad emits pointer events with `pointerType: "mouse"`, whereas + // touch reports "touch" and the Pencil reports "pen", so this never enables on + // touch-only input. Treat peek capability as a one-way latch — once a cursor + // has been seen we keep peek available for the session. + useEffect(() => { + if (pointerCanPeek || typeof window.PointerEvent !== "function") return; + const onPointer = (e: PointerEvent) => { + if (e.pointerType === "mouse") setPointerCanPeek(true); + }; + window.addEventListener("pointerover", onPointer, { passive: true }); + window.addEventListener("pointermove", onPointer, { passive: true }); + return () => { + window.removeEventListener("pointerover", onPointer); + window.removeEventListener("pointermove", onPointer); + }; + }, [pointerCanPeek]); + // Precedence (highest wins): forced (active secondary sidebar) > explicit user // pin > route request > default expanded. The force is ephemeral and never // touches the persisted pin, so dropping it restores the user's preference.