diff --git a/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx b/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx index 60512672c2..40b92eb9d5 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx +++ b/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx @@ -28,6 +28,16 @@ const mainEditorLayoutModalsModules: Array<{ // Bootstrap md breakpoint — below this we stack panels vertically on mobile const MOBILE_MQ = '(max-width: 767px)' +// Secondary check: browsers that spoof viewport width (e.g. Tor Browser) still +// expose `pointer: coarse` for real touch hardware. +const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)' + +function detectMobile() { + return ( + window.matchMedia(MOBILE_MQ).matches || + window.matchMedia(TOUCH_MQ).matches + ) +} export default function MainLayout() { const [resizing, setResizing] = useState(false) @@ -42,14 +52,17 @@ export default function MainLayout() { } = usePdfPane() const { view, pdfLayout } = useLayoutContext() - const [isMobile, setIsMobile] = useState( - () => window.matchMedia(MOBILE_MQ).matches - ) + const [isMobile, setIsMobile] = useState(detectMobile) useEffect(() => { - const mq = window.matchMedia(MOBILE_MQ) - const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches) - mq.addEventListener('change', handler) - return () => mq.removeEventListener('change', handler) + const handler = () => setIsMobile(detectMobile()) + const mq1 = window.matchMedia(MOBILE_MQ) + const mq2 = window.matchMedia(TOUCH_MQ) + mq1.addEventListener('change', handler) + mq2.addEventListener('change', handler) + return () => { + mq1.removeEventListener('change', handler) + mq2.removeEventListener('change', handler) + } }, []) // verticalSplit is always vertical; sideBySide becomes vertical on mobile diff --git a/services/web/frontend/js/shared/context/layout-context.tsx b/services/web/frontend/js/shared/context/layout-context.tsx index b4ef85685f..feafcfffb9 100644 --- a/services/web/frontend/js/shared/context/layout-context.tsx +++ b/services/web/frontend/js/shared/context/layout-context.tsx @@ -86,16 +86,24 @@ function setLayoutInLocalStorage(pdfLayout: IdeLayout) { } const MOBILE_MQ = '(max-width: 767px)' +// Secondary touch check: catches browsers that spoof viewport width (e.g. Tor +// Browser's fingerprinting resistance reports ~980px on an Android phone). +// `pointer: coarse` reflects real hardware and is not spoofed. +const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)' + +function isMobileDevice(): boolean { + return ( + window.matchMedia(MOBILE_MQ).matches || + window.matchMedia(TOUCH_MQ).matches + ) +} function getInitialLayout(): IdeLayout { const stored = localStorage.getItem('pdf.layout') - const isMobile = window.matchMedia(MOBILE_MQ).matches - // On mobile, always start in verticalSplit (editor above, PDF below). - // We must check isMobile first: a stale 'flat' in localStorage (written by - // a previous autoSave race or a desktop session) would otherwise short-circuit - // the mobile check and leave the user stuck in a flat/editor-only layout. - // The user can still collapse to flat during a session, but it does not persist. - if (isMobile) return 'verticalSplit' + // Check mobile first — must come before the stored-'flat' check so that a + // stale 'flat' (written by an old autoSave race) doesn't block the mobile + // default. isMobileDevice() also catches spoofed-viewport browsers. + if (isMobileDevice()) return 'verticalSplit' if (stored === 'flat') return 'flat' if (stored === 'split') return 'sideBySide' if (stored === 'vertical') return 'verticalSplit'