From 3ff53f0a78e14b447f00b4c5990420aaa5615046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Mon, 27 Jul 2026 18:35:14 +0200 Subject: [PATCH] standalone: start headless (chrome hidden) on small screens + treat tablets as mobile Chrome start-hidden default is now startsChromeHidden(): phones (UA-CH), tablets (coarse primary pointer at any width), and narrow windows (<=900px, any pointer). isMobileMode() drops its narrow-viewport requirement so tablets get the full mobile treatment (touch shim, preflight suppression); touch laptops stay desktop (fine primary pointer). capabilities.ts probe kept in sync. ?mobile= still overrides both ways. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CkUNWj4KWz6WfP8VwRsUNN --- web/standalone/src/components/WasmTool.tsx | 5 ++- web/standalone/src/lib/chrome-visibility.ts | 9 ++-- web/standalone/src/lib/mobile-mode.test.ts | 44 +++++++++++++++++--- web/standalone/src/lib/mobile-mode.ts | 29 +++++++++++-- web/standalone/src/preflight/capabilities.ts | 5 ++- 5 files changed, 76 insertions(+), 16 deletions(-) diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index b70a642..ea0e21d 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -823,8 +823,9 @@ export function WasmTool({ // Mobile device (features/mobile): boot installs the touch-gesture shim. // Chrome/overlay visibility is the separate runtime toggle below. const mobileUi = React.useMemo(() => isMobileMode(), []); - // Figma-like "hide UI" toggle: mobile defaults to hidden, the floating - // button / Cmd+\ flips it live; shell overlays key off this, and the layout + // Figma-like "hide UI" toggle: small screens (phones, tablets, narrow + // windows — startsChromeHidden) default to hidden, the floating button / + // Cmd+\ flips it live; shell overlays key off this, and the layout // effect below applies it to the wasm frame. const chromeHidden = useChromeHidden(); // Read-only sessions force-hide the chrome without touching the module-global diff --git a/web/standalone/src/lib/chrome-visibility.ts b/web/standalone/src/lib/chrome-visibility.ts index d310a75..411bd80 100644 --- a/web/standalone/src/lib/chrome-visibility.ts +++ b/web/standalone/src/lib/chrome-visibility.ts @@ -8,14 +8,15 @@ * console toggle) key their visibility off it. * * Session semantics, like Figma: nothing is persisted — a reload restores the - * device default (isMobileMode: mobile → hidden, desktop → shown; `?mobile=` - * still forces it). Being module-global the toggled state survives SPA + * device default (startsChromeHidden: small screens — phones, tablets, narrow + * windows — → hidden, desktop → shown; `?mobile=` still forces it). Being + * module-global the toggled state survives SPA * navigation (e.g. editor → Home keeps the badge hidden); tool switches are * full page loads, so in practice that only affects Home. */ import { useSyncExternalStore } from "react"; -import { isMobileMode } from "./mobile-mode"; +import { startsChromeHidden } from "./mobile-mode"; // Resolved lazily so merely importing the module never touches `window` // (unit tests run in the node environment). @@ -23,7 +24,7 @@ let hidden: boolean | null = null; const listeners = new Set<() => void>(); export function getChromeHidden(): boolean { - hidden ??= typeof window === "undefined" ? false : isMobileMode(); + hidden ??= typeof window === "undefined" ? false : startsChromeHidden(); return hidden; } diff --git a/web/standalone/src/lib/mobile-mode.test.ts b/web/standalone/src/lib/mobile-mode.test.ts index a34f860..b60914f 100644 --- a/web/standalone/src/lib/mobile-mode.test.ts +++ b/web/standalone/src/lib/mobile-mode.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; -import { isMobileMode, type MobileModeWindow } from "./mobile-mode"; +import { isMobileMode, startsChromeHidden, type MobileModeWindow } from "./mobile-mode"; /** * Mobile-mode resolution (features/mobile): the explicit `?mobile=` URL param * always wins; otherwise fall back to device detection (UA-CH mobile flag, or - * coarse pointer + narrow viewport — the same signals capabilities.ts warns on). + * a coarse primary pointer at any width — tablets are mobile; the same signals + * capabilities.ts warns on). */ function fakeWin(opts: { @@ -43,10 +44,11 @@ describe("isMobileMode", () => { expect(isMobileMode(fakeWin({ uaMobile: false }))).toBe(false); }); - it("auto-detects via coarse pointer + narrow viewport", () => { + it("auto-detects via coarse primary pointer, at any width", () => { expect(isMobileMode(fakeWin({ coarse: true, narrow: true }))).toBe(true); - // a touch-screen desktop (coarse but wide) is NOT mobile - expect(isMobileMode(fakeWin({ coarse: true, narrow: false }))).toBe(false); + // a WIDE coarse-primary device is a tablet in landscape — mobile too + expect(isMobileMode(fakeWin({ coarse: true, narrow: false }))).toBe(true); + // a narrow window with a fine pointer is just a small desktop window expect(isMobileMode(fakeWin({ coarse: false, narrow: true }))).toBe(false); }); @@ -55,3 +57,35 @@ describe("isMobileMode", () => { expect(isMobileMode(fakeWin({ noMatchMedia: true }))).toBe(false); }); }); + +/** + * Chrome start-hidden resolution: everything isMobileMode covers (phones + + * tablets), plus narrow viewports with any pointer; `?mobile=` still overrides + * both ways. + */ +describe("startsChromeHidden", () => { + it("?mobile= overrides both ways", () => { + expect(startsChromeHidden(fakeWin({ search: "?mobile=1" }))).toBe(true); + expect( + startsChromeHidden(fakeWin({ search: "?mobile=0", uaMobile: true, coarse: true, narrow: true })), + ).toBe(false); + }); + + it("phones start hidden (UA-CH mobile)", () => { + expect(startsChromeHidden(fakeWin({ uaMobile: true }))).toBe(true); + }); + + it("tablets start hidden: coarse primary pointer even when WIDE (iPad landscape)", () => { + expect(startsChromeHidden(fakeWin({ uaMobile: false, coarse: true, narrow: false }))).toBe(true); + }); + + it("narrow viewports start hidden even with a fine pointer (small window)", () => { + expect(startsChromeHidden(fakeWin({ coarse: false, narrow: true }))).toBe(true); + }); + + it("wide fine-pointer desktops start shown", () => { + expect(startsChromeHidden(fakeWin({ coarse: false, narrow: false }))).toBe(false); + expect(startsChromeHidden(fakeWin({}))).toBe(false); + expect(startsChromeHidden(fakeWin({ noMatchMedia: true }))).toBe(false); + }); +}); diff --git a/web/standalone/src/lib/mobile-mode.ts b/web/standalone/src/lib/mobile-mode.ts index 55edd9a..4e94235 100644 --- a/web/standalone/src/lib/mobile-mode.ts +++ b/web/standalone/src/lib/mobile-mode.ts @@ -8,8 +8,11 @@ * * - `?mobile=1` / `?mobile=0` (also true/false) override everything — the * deterministic switch for tests and for users on unusual devices. - * - otherwise auto-detect: UA-CH `userAgentData.mobile`, or coarse pointer + - * narrow viewport (the same signals capabilities.ts warns on). + * - otherwise auto-detect: UA-CH `userAgentData.mobile`, or a coarse PRIMARY + * pointer at any width — tablets count as mobile (Android tablets report + * `mobile: false`, iPads don't expose UA-CH; both need the touch shim). + * Touch laptops keep a fine primary pointer (trackpad), so they stay + * desktop. Same signals capabilities.ts warns on. */ /** The window surface isMobileMode reads — narrow, so tests can fake it. */ @@ -31,5 +34,25 @@ export function isMobileMode( const mm = win.matchMedia; if (typeof mm !== "function") return false; - return mm("(pointer: coarse)").matches && mm("(max-width: 900px)").matches; + return mm("(pointer: coarse)").matches; +} + +/** + * Should the editor chrome START hidden ("headless")? Everything isMobileMode + * covers (phones + tablets), plus a narrow viewport with any pointer — a small + * desktop window starts headless too, since the chrome doesn't fit. `?mobile=0` + * still forces shown. Start state only — the floating button / Cmd+\ toggle it + * live (chrome-visibility). + */ +export function startsChromeHidden( + win: MobileModeWindow = window as unknown as MobileModeWindow, +): boolean { + const param = new URLSearchParams(win.location.search).get("mobile"); + if (param === "0" || param === "false") return false; + + if (isMobileMode(win)) return true; + + const mm = win.matchMedia; + if (typeof mm !== "function") return false; + return mm("(max-width: 900px)").matches; } diff --git a/web/standalone/src/preflight/capabilities.ts b/web/standalone/src/preflight/capabilities.ts index a132356..ba2352a 100644 --- a/web/standalone/src/preflight/capabilities.ts +++ b/web/standalone/src/preflight/capabilities.ts @@ -214,10 +214,11 @@ export function probeCapabilities(): CapabilityReport { // --- warn: mobile ---------------------------------------------------------- const uaMobile = nav?.userAgentData?.mobile; + // Coarse PRIMARY pointer at any width = phone or tablet (touch laptops keep + // a fine primary pointer) — mirrors lib/mobile-mode isMobileMode. const coarsePointer = typeof matchMedia === "function" && - matchMedia("(pointer: coarse)").matches && - matchMedia("(max-width: 900px)").matches; + matchMedia("(pointer: coarse)").matches; const isMobile = uaMobile === true || coarsePointer; info.mobile = isMobile; if (isMobile) {