Files
openchamber/packages/ui/src/lib/runtimeSurface.ts
T
Bohdan Triapitsyn 841eca5720 feat(surface): switch app shells when the viewport crosses the phone threshold
The mobile-vs-desktop surface is stamped once at boot, so a browser
window narrowed past the phone threshold kept the desktop shell (and
its legacy squeezed layout) until a manual reload. A viewport watcher
now reloads into the other shell once the resize settles — the same
mechanism the old Settings toggle used. Fixed shells (Capacitor,
desktop, VS Code) and ?surface= overrides never switch.

With the new mobile app reachable this way, the old/new mobile layout
preference is gone: phones always get the mobile app.
2026-08-24 16:08:04 +03:00

118 lines
4.2 KiB
TypeScript

import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { isCapacitorApp } from '@/lib/platform';
export type HostedSurface = 'desktop' | 'mobile';
declare global {
interface Window {
__OPENCHAMBER_SURFACE__?: HostedSurface;
}
}
const MOBILE_SURFACE_MAX_WIDTH = 768;
const SURFACE_SWITCH_DEBOUNCE_MS = 800;
const isTouchOrCoarsePointer = (): boolean => {
if (typeof window === 'undefined') return false;
const coarsePointer = typeof window.matchMedia === 'function'
? window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(hover: none)').matches
: false;
const touchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
return coarsePointer || touchPoints > 0;
};
const hasSurfaceUrlOverride = (): boolean => {
if (typeof window === 'undefined') return false;
const override = new URLSearchParams(window.location.search).get('surface');
return override === 'mobile' || override === 'desktop';
};
/** Viewport half of the surface decision; re-evaluated on resize by the watcher. */
const isPhoneViewport = (): boolean => {
if (typeof window === 'undefined') return false;
const width = Math.min(
window.innerWidth || Number.POSITIVE_INFINITY,
window.screen?.width || Number.POSITIVE_INFINITY,
);
return Number.isFinite(width)
&& width <= MOBILE_SURFACE_MAX_WIDTH
&& isTouchOrCoarsePointer();
};
/**
* Single authority for the mobile-vs-desktop surface decision.
*
* Priority: explicit stamp (set once at boot) → URL override → Capacitor
* shell (always the mobile surface) → desktop shells → phone heuristic.
*/
const detectHostedSurface = (): HostedSurface => {
if (typeof window === 'undefined') return 'desktop';
const explicitSurface = window.__OPENCHAMBER_SURFACE__;
if (explicitSurface === 'mobile' || explicitSurface === 'desktop') {
return explicitSurface;
}
const override = new URLSearchParams(window.location.search).get('surface');
if (override === 'mobile' || override === 'desktop') {
return override;
}
if (isCapacitorApp()) return 'mobile';
if (isDesktopShell() || isVSCodeRuntime()) return 'desktop';
return isPhoneViewport() ? 'mobile' : 'desktop';
};
/**
* Decides the surface once and stamps it on `window` so every later
* `isMobileSurfaceRuntime()` call (perf tuning, sync paging, device info)
* reads the same stable answer instead of re-running viewport heuristics.
*/
export const resolveHostedSurface = (): HostedSurface => {
const surface = detectHostedSurface();
if (typeof window !== 'undefined') {
window.__OPENCHAMBER_SURFACE__ = surface;
}
return surface;
};
export const isMobileSurfaceRuntime = (): boolean => detectHostedSurface() === 'mobile';
/**
* The surface is stamped once at boot, so a browser window that crosses the
* phone threshold after load would otherwise keep the wrong app shell (the
* app trees, stores, and sync bootstrap differ, so an in-place switch is not
* safe). Watch for the viewport heuristic disagreeing with the stamp and
* reload — the same mechanism a surface change has always used — once the
* resize settles. Fixed shells never switch: Capacitor is always mobile,
* desktop/VS Code shells are always desktop, and an explicit ?surface=
* override wins over the heuristic.
*/
export const watchHostedSurfaceViewport = (): (() => void) => {
if (typeof window === 'undefined') return () => {};
if (isCapacitorApp() || isDesktopShell() || isVSCodeRuntime()) return () => {};
if (hasSurfaceUrlOverride()) return () => {};
let timeout: ReturnType<typeof setTimeout> | null = null;
const handleResize = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
const stamped = window.__OPENCHAMBER_SURFACE__;
const desired: HostedSurface = isPhoneViewport() ? 'mobile' : 'desktop';
if (stamped && stamped !== desired) {
window.__OPENCHAMBER_SURFACE__ = undefined;
window.location.reload();
}
}, SURFACE_SWITCH_DEBOUNCE_MS);
};
window.addEventListener('resize', handleResize);
return () => {
if (timeout) clearTimeout(timeout);
window.removeEventListener('resize', handleResize);
};
};