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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkUNWj4KWz6WfP8VwRsUNN
This commit is contained in:
Gergő Törcsvári 2026-07-27 18:35:14 +02:00
commit 3ff53f0a78
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 76 additions and 16 deletions

View file

@ -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

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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;
}

View file

@ -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) {