From 874f3518a6e25a4856fe0d215314667d45000a88 Mon Sep 17 00:00:00 2001 From: Jono Date: Wed, 23 Sep 2026 18:05:41 -0700 Subject: [PATCH] feat(client): fullscreen toggle in bottom chrome strip Hide browser chrome (phone/desktop) via the Fullscreen API: a new icon-only chrome button toggles document fullscreen with navigationUI: hide, tracks external exits through fullscreenchange, and no-ops where the API is unavailable. Sidesteps the PWA-install cert problem for LAN use until a trusted cert story lands. --- client/src/App.test.tsx | 62 ++++++++++++++++++++++++++++++++++++++++ client/src/App.tsx | 20 ++++++++++++- client/src/fullscreen.ts | 33 +++++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 client/src/fullscreen.ts diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 98d5931..f58c602 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -370,6 +370,7 @@ describe("App — chrome button tooltips", () => { // independent of that detail. { name: /now playing/i, tipId: "now playing" }, { name: "layout editor", tipId: "layout editor" }, + { name: "fullscreen", tipId: "fullscreen" }, { name: "settings", tipId: "settings" }, ]; for (const c of cases) { @@ -393,6 +394,7 @@ describe("App — chrome button tooltips", () => { screen.getByRole("button", { name: "manual control" }), screen.getByRole("button", { name: /now playing/i }), screen.getByRole("button", { name: "layout editor" }), + screen.getByRole("button", { name: "fullscreen" }), screen.getByRole("button", { name: "settings" }), ]; for (const b of iconOnly) { @@ -1110,3 +1112,63 @@ describe("App — session lock takeover", () => { expect(screen.getByRole("button", { name: /running programs/i }).getAttribute("disabled")).toBe(""); }); }); + +/* --------------------------------------------------------------------- + Fullscreen chrome toggle (phone-friendly full-surface mode). + jsdom implements no Fullscreen API, so we stub the three members the + hook touches and drive state through the DOM's "fullscreenchange" + event, exactly as a real browser would. + --------------------------------------------------------------------- */ +describe("App — fullscreen toggle", () => { + afterEach(cleanup); + beforeEach(() => { + send.mockReset(); + window.history.replaceState(null, "", "/?demo=default"); + }); + afterEach(() => { + const doc = document as unknown as Record; + delete doc.fullscreenEnabled; + delete doc.fullscreenElement; + delete (document.documentElement as unknown as Record).requestFullscreen; + delete doc.exitFullscreen; + }); + + it("requests fullscreen on tap, then exits on the second tap", async () => { + render(); + const button = screen.getByRole("button", { name: "fullscreen" }); + expect(button.getAttribute("aria-pressed")).toBe("false"); + + const doc = document as unknown as Record; + doc.fullscreenEnabled = true; + + const requestFullscreen = vi.fn(() => Promise.resolve()); + (document.documentElement as unknown as Record).requestFullscreen = + requestFullscreen; + + fireEvent.pointerDown(button); + await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(1)); + + // A real browser flips fullscreenElement then fires the event. + doc.fullscreenElement = document.documentElement; + fireEvent(document, new Event("fullscreenchange")); + const pressed = screen.getByRole("button", { name: "exit fullscreen" }); + expect(pressed.getAttribute("aria-pressed")).toBe("true"); + + const exitFullscreen = vi.fn(() => Promise.resolve()); + (document as unknown as Record).exitFullscreen = exitFullscreen; + + fireEvent.pointerDown(pressed); + await waitFor(() => expect(exitFullscreen).toHaveBeenCalledTimes(1)); + + doc.fullscreenElement = null; + fireEvent(document, new Event("fullscreenchange")); + expect(screen.getByRole("button", { name: "fullscreen" }).getAttribute("aria-pressed")).toBe("false"); + }); + + it("is a no-op when the Fullscreen API is unavailable", () => { + render(); + // No stubs installed: fullscreenEnabled is undefined. + fireEvent.pointerDown(screen.getByRole("button", { name: "fullscreen" })); + expect(screen.getByRole("button", { name: "fullscreen" }).getAttribute("aria-pressed")).toBe("false"); + }); +}); diff --git a/client/src/App.tsx b/client/src/App.tsx index f6e4a27..ad908c0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Lock as LockIcon, Moon as MoonIcon, Pencil as PencilIcon, Settings as SettingsIcon, Globe as GlobeIcon } from "lucide-react"; +import { Lock as LockIcon, Moon as MoonIcon, Pencil as PencilIcon, Settings as SettingsIcon, Globe as GlobeIcon, Maximize as MaximizeIcon, Minimize as MinimizeIcon } from "lucide-react"; import { LayoutGrid as LayoutGridIcon, Music as MusicIcon, PointerIcon } from "lucide-react"; import { useDeckdSocket } from "./socket"; import { ButtonGrid } from "./ButtonGrid"; @@ -30,6 +30,7 @@ import { } from "./settings-store"; import type { CSSProperties } from "react"; import { useWakeLock } from "./wake-lock"; +import { useFullscreen } from "./fullscreen"; import { getDemoLayout, getDemoView, MEDIA_DEMO_STATES, MPRIS_DEMO_STATES, EDITOR_DEMO_LAYOUTS } from "./demo"; import { usePlaygroundDaemon } from "./playground/usePlaygroundDaemon"; import { Icon } from "./Icon"; @@ -322,6 +323,11 @@ export function App() { // screen on. Visibility is handled inside the hook. useWakeLock(wakeLock.enabled && status === "open"); + // Fullscreen toggle (hides browser chrome on phone/desktop). The + // ``fullscreenchange`` sync inside the hook keeps the icon honest when + // the browser exits fullscreen on its own (back gesture, Esc). + const [isFullscreen, toggleFullscreen] = useFullscreen(); + const press = (id: string) => send({ type: "press", id }); const jog = (id: string, delta: number) => send({ type: "jog", id, delta }); const jogEnd = (id: string, velocity: number) => send({ type: "jog_end", id, velocity }); @@ -405,6 +411,7 @@ export function App() { // Refs to the chrome buttons themselves, so focus restoration knows // where to land when a view closes. const trackpadBtnRef = useRef(null); + const fullscreenBtnRef = useRef(null); const settingsBtnRef = useRef(null); const mediaBtnRef = useRef(null); const editorBtnRef = useRef(null); @@ -968,6 +975,17 @@ export function App() { + + +