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:
parent
fdc4583288
commit
3ff53f0a78
5 changed files with 76 additions and 16 deletions
|
|
@ -823,8 +823,9 @@ export function WasmTool({
|
||||||
// Mobile device (features/mobile): boot installs the touch-gesture shim.
|
// Mobile device (features/mobile): boot installs the touch-gesture shim.
|
||||||
// Chrome/overlay visibility is the separate runtime toggle below.
|
// Chrome/overlay visibility is the separate runtime toggle below.
|
||||||
const mobileUi = React.useMemo(() => isMobileMode(), []);
|
const mobileUi = React.useMemo(() => isMobileMode(), []);
|
||||||
// Figma-like "hide UI" toggle: mobile defaults to hidden, the floating
|
// Figma-like "hide UI" toggle: small screens (phones, tablets, narrow
|
||||||
// button / Cmd+\ flips it live; shell overlays key off this, and the layout
|
// 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.
|
// effect below applies it to the wasm frame.
|
||||||
const chromeHidden = useChromeHidden();
|
const chromeHidden = useChromeHidden();
|
||||||
// Read-only sessions force-hide the chrome without touching the module-global
|
// Read-only sessions force-hide the chrome without touching the module-global
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,15 @@
|
||||||
* console toggle) key their visibility off it.
|
* console toggle) key their visibility off it.
|
||||||
*
|
*
|
||||||
* Session semantics, like Figma: nothing is persisted — a reload restores the
|
* Session semantics, like Figma: nothing is persisted — a reload restores the
|
||||||
* device default (isMobileMode: mobile → hidden, desktop → shown; `?mobile=`
|
* device default (startsChromeHidden: small screens — phones, tablets, narrow
|
||||||
* still forces it). Being module-global the toggled state survives SPA
|
* 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
|
* navigation (e.g. editor → Home keeps the badge hidden); tool switches are
|
||||||
* full page loads, so in practice that only affects Home.
|
* full page loads, so in practice that only affects Home.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { isMobileMode } from "./mobile-mode";
|
import { startsChromeHidden } from "./mobile-mode";
|
||||||
|
|
||||||
// Resolved lazily so merely importing the module never touches `window`
|
// Resolved lazily so merely importing the module never touches `window`
|
||||||
// (unit tests run in the node environment).
|
// (unit tests run in the node environment).
|
||||||
|
|
@ -23,7 +24,7 @@ let hidden: boolean | null = null;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
export function getChromeHidden(): boolean {
|
export function getChromeHidden(): boolean {
|
||||||
hidden ??= typeof window === "undefined" ? false : isMobileMode();
|
hidden ??= typeof window === "undefined" ? false : startsChromeHidden();
|
||||||
return hidden;
|
return hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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
|
* Mobile-mode resolution (features/mobile): the explicit `?mobile=` URL param
|
||||||
* always wins; otherwise fall back to device detection (UA-CH mobile flag, or
|
* 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: {
|
function fakeWin(opts: {
|
||||||
|
|
@ -43,10 +44,11 @@ describe("isMobileMode", () => {
|
||||||
expect(isMobileMode(fakeWin({ uaMobile: false }))).toBe(false);
|
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);
|
expect(isMobileMode(fakeWin({ coarse: true, narrow: true }))).toBe(true);
|
||||||
// a touch-screen desktop (coarse but wide) is NOT mobile
|
// a WIDE coarse-primary device is a tablet in landscape — mobile too
|
||||||
expect(isMobileMode(fakeWin({ coarse: true, narrow: false }))).toBe(false);
|
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);
|
expect(isMobileMode(fakeWin({ coarse: false, narrow: true }))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -55,3 +57,35 @@ describe("isMobileMode", () => {
|
||||||
expect(isMobileMode(fakeWin({ noMatchMedia: true }))).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@
|
||||||
*
|
*
|
||||||
* - `?mobile=1` / `?mobile=0` (also true/false) override everything — the
|
* - `?mobile=1` / `?mobile=0` (also true/false) override everything — the
|
||||||
* deterministic switch for tests and for users on unusual devices.
|
* deterministic switch for tests and for users on unusual devices.
|
||||||
* - otherwise auto-detect: UA-CH `userAgentData.mobile`, or coarse pointer +
|
* - otherwise auto-detect: UA-CH `userAgentData.mobile`, or a coarse PRIMARY
|
||||||
* narrow viewport (the same signals capabilities.ts warns on).
|
* 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. */
|
/** The window surface isMobileMode reads — narrow, so tests can fake it. */
|
||||||
|
|
@ -31,5 +34,25 @@ export function isMobileMode(
|
||||||
|
|
||||||
const mm = win.matchMedia;
|
const mm = win.matchMedia;
|
||||||
if (typeof mm !== "function") return false;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -214,10 +214,11 @@ export function probeCapabilities(): CapabilityReport {
|
||||||
|
|
||||||
// --- warn: mobile ----------------------------------------------------------
|
// --- warn: mobile ----------------------------------------------------------
|
||||||
const uaMobile = nav?.userAgentData?.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 =
|
const coarsePointer =
|
||||||
typeof matchMedia === "function" &&
|
typeof matchMedia === "function" &&
|
||||||
matchMedia("(pointer: coarse)").matches &&
|
matchMedia("(pointer: coarse)").matches;
|
||||||
matchMedia("(max-width: 900px)").matches;
|
|
||||||
const isMobile = uaMobile === true || coarsePointer;
|
const isMobile = uaMobile === true || coarsePointer;
|
||||||
info.mobile = isMobile;
|
info.mobile = isMobile;
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue