diff --git a/client/native-shell/shell.css b/client/native-shell/shell.css
index 9b83e87a6..ee1e85e3e 100644
--- a/client/native-shell/shell.css
+++ b/client/native-shell/shell.css
@@ -983,7 +983,7 @@ a.linkbtn {
centred in the other half, the way a split sign-in page uses the width.
Upright, the photo band grows with the screen instead of stopping at a
phone's 440px. Logical sides, so Arabic mirrors it. */
-@media (min-width: 900px) and (min-height: 600px) and (orientation: landscape) {
+@media (min-width: 840px) and (min-height: 600px) and (orientation: landscape) {
body::after {
inset: 16px auto 16px 16px;
inset-block: 16px;
diff --git a/client/src/hooks/sizeClass.ts b/client/src/hooks/sizeClass.ts
index 84a48357a..3475bae96 100644
--- a/client/src/hooks/sizeClass.ts
+++ b/client/src/hooks/sizeClass.ts
@@ -2,7 +2,9 @@ import { useSyncExternalStore } from 'react';
/**
* The touch shell's size classes, the web's answer to UIKit's compact and
- * regular widths.
+ * regular widths and to Android's window size classes. They are read off the
+ * window's CSS size, which is the same in the iOS and the Android app (a CSS
+ * pixel is a point there and a dp here), so both apps lay out alike.
*
* compact every phone, upright or on its side, and the narrow Split View and
* Stage Manager windows. The shell as it has always been: the dock at
@@ -11,8 +13,10 @@ import { useSyncExternalStore } from 'react';
* any orientation or window above a third of the screen, a foldable
* unfolded. A navigation rail on the leading edge takes the dock's
* place, sheets become centred form sheets, lists get columns.
- * split from 900pt across: room for two panes, so the trip's plan sits next
- * to its map and settings show their sections beside the page.
+ * split from 840pt across, Android's expanded width: room for two panes, so
+ * the trip's plan sits next to its map and settings show their
+ * sections beside the page. An Android foldable unfolded on its side
+ * (about 841dp) has it, an 11" iPad upright (834pt) does not.
*
* The height bound keeps a phone on its side compact: no phone is taller than
* 500pt in landscape, no tablet or unfolded foldable is shorter.
@@ -22,7 +26,7 @@ import { useSyncExternalStore } from 'react';
* keep the three in step (sizeClass.test.ts holds them together).
*/
export const WIDE_QUERY = '(min-width: 600px) and (min-height: 501px)';
-export const SPLIT_QUERY = '(min-width: 900px) and (min-height: 501px)';
+export const SPLIT_QUERY = '(min-width: 840px) and (min-height: 501px)';
function matches(query: string): boolean {
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(query).matches;
diff --git a/client/src/mobile/MobileShell.tsx b/client/src/mobile/MobileShell.tsx
index af6b25037..67ada03b9 100644
--- a/client/src/mobile/MobileShell.tsx
+++ b/client/src/mobile/MobileShell.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useRef } from 'react'
+import { CSSProperties, ReactNode, useRef } from 'react'
import { useMatch } from 'react-router'
import BottomNav from '../components/Layout/BottomNav'
import ErrorBoundary from '../components/shared/ErrorBoundary'
@@ -11,6 +11,7 @@ import MToastHost from './components/MToastHost'
import MEdgeBack from './components/MEdgeBack'
import { useNativeCapabilities } from '../native/capabilities'
import { isNativeIos } from '../native/platform'
+import { useWindowFold } from '../native/windowFold'
import './mobile.css'
interface MobileShellProps {
@@ -46,6 +47,12 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
// On a wide window (an iPad, a foldable unfolded) the rail on the leading edge
// replaces the dock, and the page column starts after it.
const wide = useIsWide()
+ // A foldable's fold, as the Android app reports it: two-pane screens end their
+ // leading pane there (mobile.css, data-fold).
+ const fold = useWindowFold()
+ const foldStyle = fold
+ ? ({ '--m-fold-start': `${fold.start}px`, '--m-fold-end': `${fold.end}px` } as CSSProperties)
+ : undefined
if (!isPhone) {
return (
@@ -59,7 +66,7 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
return (
// min-h-svh, not min-h-dvh: dvh grows while Safari's toolbar retracts, so a
// dvh page length would chase the animation it just triggered.
-
+
{/* Background and screen gradient live on their own viewport-sized layer.
The shell root is as tall as the document now, and --m-scr is a radial
gradient measured against its element box, so on it the light cone would
diff --git a/client/src/mobile/components/MSheet.tsx b/client/src/mobile/components/MSheet.tsx
index 71f0dd295..c2daea612 100644
--- a/client/src/mobile/components/MSheet.tsx
+++ b/client/src/mobile/components/MSheet.tsx
@@ -47,7 +47,7 @@ const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold
* rather than a panel across the screen: compact for a short list of actions,
* regular for a form, large for a sheet that holds a page of its own.
*/
-export type MSheetWidth = 'compact' | 'regular' | 'large'
+type MSheetWidth = 'compact' | 'regular' | 'large'
// On a wide window (hooks/sizeClass.ts) a docked or floating sheet becomes
// what iPadOS shows there: a form sheet centred over the dimmed page, as wide
@@ -72,7 +72,7 @@ const POSITION: Record
= {
* (the trip's More), the way iPadOS shows a menu next to the button that
* opened it rather than in the middle of the screen.
*/
-export type MSheetWideAnchor = 'center' | 'rail-foot'
+type MSheetWideAnchor = 'center' | 'rail-foot'
const WIDE_ANCHOR: Record = {
center: WIDE_POSITION,
'rail-foot': 'wide:top-auto wide:translate-y-0 wide:end-auto wide:bottom-[calc(env(safe-area-inset-bottom,0px)+12px)] wide:start-[calc(var(--m-rail-w)+6px)]',
diff --git a/client/src/mobile/keyboard/MKeyboardHost.tsx b/client/src/mobile/keyboard/MKeyboardHost.tsx
index 91c6427f9..738774f50 100644
--- a/client/src/mobile/keyboard/MKeyboardHost.tsx
+++ b/client/src/mobile/keyboard/MKeyboardHost.tsx
@@ -1,6 +1,5 @@
import { useEffect } from 'react';
import { useNativeCapabilities } from '../../native/capabilities';
-import { isNativeIos } from '../../native/platform';
import { reportNativeFailure } from '../../native/reportFailure';
import { TrekShell } from '../../native/trekShell';
import { findShortcut, toNativeCommand, useShortcutRegistry } from './shortcuts';
@@ -8,14 +7,16 @@ import { findShortcut, toNativeCommand, useShortcutRegistry } from './shortcuts'
/**
* Answers the touch shell's keyboard shortcuts (useShortcuts), once for every
* screen. In a browser it listens for the keys itself. In the app the binary
- * takes them over as native key commands when it can (feature keyCommands),
- * which puts them in the overlay a held ⌘ shows and in the iPadOS menu bar;
- * the page then leaves those keys alone, so nothing runs twice.
+ * takes them over as native key commands when it can (feature keyCommands):
+ * on iOS they show in the overlay a held ⌘ shows and in the iPadOS menu bar,
+ * on Android in the system's keyboard shortcuts list. The page then leaves
+ * those keys alone, so nothing runs twice. Only a shell that has the feature
+ * reports it, so the feature alone decides, whatever the platform.
*/
export default function MKeyboardHost() {
const lists = useShortcutRegistry((s) => s.lists);
const { caps } = useNativeCapabilities();
- const native = isNativeIos() && caps.features.includes('keyCommands');
+ const native = caps.features.includes('keyCommands');
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
diff --git a/client/src/mobile/mobile.css b/client/src/mobile/mobile.css
index 9610a8c34..fb22e0dac 100644
--- a/client/src/mobile/mobile.css
+++ b/client/src/mobile/mobile.css
@@ -589,6 +589,24 @@
--m-pane-trail-w: env(viewport-segment-width 1 0, 50vw);
}
}
+/* The Android app reports a foldable's fold itself (native/windowFold.ts):
+ MobileShell marks a vertical fold that separates the window with data-fold
+ and its place in --m-fold-start/--m-fold-end, and the panes meet there. The
+ leading side follows the reading direction, the fold's place does not. */
+.m-root[data-fold='vertical'] {
+ --m-pane-aside-w: calc(var(--m-fold-start) - var(--m-rail-w, 0px));
+ --m-pane-trail-w: calc(100vw - var(--m-fold-end));
+}
+.m-root[data-fold='vertical'] .m-trip {
+ --trip-pane-open: calc(var(--m-fold-start) - var(--m-rail-w, 0px));
+}
+:root[dir='rtl'] .m-root[data-fold='vertical'] {
+ --m-pane-aside-w: calc(100vw - var(--m-fold-end) - var(--m-rail-w, 0px));
+ --m-pane-trail-w: var(--m-fold-start);
+}
+:root[dir='rtl'] .m-root[data-fold='vertical'] .m-trip {
+ --trip-pane-open: calc(100vw - var(--m-fold-end) - var(--m-rail-w, 0px));
+}
.m-pane-aside {
width: var(--m-pane-aside-w);
}
diff --git a/client/src/mobile/screens/trip/tabs/collabPanes.ts b/client/src/mobile/screens/trip/tabs/collabPanes.ts
index 0549f5112..a66bae415 100644
--- a/client/src/mobile/screens/trip/tabs/collabPanes.ts
+++ b/client/src/mobile/screens/trip/tabs/collabPanes.ts
@@ -2,7 +2,7 @@ import type { MTripCollabTab } from '../MTripShell';
/** The collab boards that can stand beside the chat: everything but the chat itself. */
const BOARD_TABS = ['notes', 'links', 'polls'] as const;
-export type CollabBoardTab = (typeof BOARD_TABS)[number];
+type CollabBoardTab = (typeof BOARD_TABS)[number];
/** Whether a collab tab is one of the boards (not the chat, nor a tab this screen does not know). */
export function isCollabBoard(tab: string): tab is CollabBoardTab {
diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts
index b37a4c28b..5137d8667 100644
--- a/client/src/native/trekShell.ts
+++ b/client/src/native/trekShell.ts
@@ -170,7 +170,7 @@ export interface SharedFilePayload {
* pixels, Android's size classes, and a foldable's posture with the hinge's
* place in the window. `posture` is none on a device without a fold.
*/
-interface NativeWindowLayout {
+export interface NativeWindowLayout {
width: number;
height: number;
widthClass: 'compact' | 'medium' | 'expanded';
diff --git a/client/src/native/windowFold.test.ts b/client/src/native/windowFold.test.ts
new file mode 100644
index 000000000..63ce9c790
--- /dev/null
+++ b/client/src/native/windowFold.test.ts
@@ -0,0 +1,85 @@
+// FE-NATIVE-FOLD-001 onwards: the fold an Android foldable reports, as two-pane screens use it.
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { NativeWindowLayout } from './trekShell';
+import { _resetWindowFold, foldOf, useWindowFold } from './windowFold';
+
+const shell = vi.hoisted(() => ({
+ features: [] as string[],
+ layout: null as unknown,
+ listener: null as null | ((layout: unknown) => void),
+}));
+
+vi.mock('./capabilities', () => ({
+ hasNativeFeature: (feature: string) => shell.features.includes(feature),
+ useNativeCapabilities: () => ({
+ caps: { shellApi: 3, platform: 'android', appVersion: '', build: '', features: shell.features },
+ updateRequired: false,
+ }),
+}));
+vi.mock('./trekShell', () => ({
+ TrekShell: {
+ getWindowLayout: vi.fn(() => Promise.resolve(shell.layout)),
+ addListener: vi.fn((_event: string, fn: (layout: unknown) => void) => {
+ shell.listener = fn;
+ return Promise.resolve({ remove: () => Promise.resolve() });
+ }),
+ },
+}));
+
+function layout(over: Partial = {}): NativeWindowLayout {
+ return {
+ width: 841,
+ height: 701,
+ widthClass: 'expanded',
+ heightClass: 'medium',
+ posture: 'halfOpened',
+ orientation: 'vertical',
+ separating: true,
+ hinge: { left: 420, top: 0, right: 421, bottom: 701 },
+ ...over,
+ };
+}
+
+beforeEach(() => {
+ _resetWindowFold();
+ shell.features = [];
+ shell.layout = null;
+ shell.listener = null;
+});
+
+afterEach(() => _resetWindowFold());
+
+describe('foldOf', () => {
+ it('FE-NATIVE-FOLD-001: a book posture splits the window at its hinge', () => {
+ expect(foldOf(layout())).toEqual({ start: 420, end: 421 });
+ });
+
+ it('FE-NATIVE-FOLD-002: a seamless flat fold, a horizontal one or none at all splits nothing', () => {
+ expect(foldOf(layout({ posture: 'flat', separating: false }))).toBeNull();
+ expect(foldOf(layout({ orientation: 'horizontal' }))).toBeNull();
+ expect(foldOf(layout({ posture: 'none', hinge: undefined }))).toBeNull();
+ expect(foldOf(null)).toBeNull();
+ // A hinge outside the window is not one to lay panes against.
+ expect(foldOf(layout({ hinge: { left: 0, top: 0, right: 0, bottom: 701 } }))).toBeNull();
+ });
+});
+
+describe('useWindowFold', () => {
+ it('FE-NATIVE-FOLD-003: without the shell feature there is no fold to follow', () => {
+ const { result } = renderHook(() => useWindowFold());
+ expect(result.current).toBeNull();
+ expect(shell.listener).toBeNull();
+ });
+
+ it('FE-NATIVE-FOLD-004: follows the layout the Android shell reports, as the device folds and unfolds', async () => {
+ shell.features = ['windowLayout'];
+ shell.layout = layout();
+ const { result } = renderHook(() => useWindowFold());
+ await waitFor(() => expect(result.current).toEqual({ start: 420, end: 421 }));
+
+ act(() => shell.listener?.(layout({ posture: 'flat', separating: false })));
+ expect(result.current).toBeNull();
+ });
+});
diff --git a/client/src/native/windowFold.ts b/client/src/native/windowFold.ts
new file mode 100644
index 000000000..14e1090d3
--- /dev/null
+++ b/client/src/native/windowFold.ts
@@ -0,0 +1,72 @@
+import { useEffect, useSyncExternalStore } from 'react';
+
+import { hasNativeFeature, useNativeCapabilities } from './capabilities';
+import { reportNativeFailure } from './reportFailure';
+import { TrekShell, type NativeWindowLayout } from './trekShell';
+
+/** A fold down the window, in CSS pixels from its left edge: where it begins and ends. */
+export interface WindowFold {
+ start: number;
+ end: number;
+}
+
+/**
+ * The fold a two-pane screen should respect: a vertical one that separates the
+ * window into two sides, as a foldable half open like a book has, or a device
+ * whose two screens meet there. A flat fold that does not separate (a Galaxy
+ * Fold opened all the way) is one seamless screen, and a horizontal one runs
+ * across both panes rather than between them.
+ */
+export function foldOf(layout: NativeWindowLayout | null): WindowFold | null {
+ if (!layout?.hinge || layout.posture === 'none') return null;
+ if (layout.orientation !== 'vertical' || !layout.separating) return null;
+ const { left, right } = layout.hinge;
+ if (!(left > 0 && right >= left && right < layout.width)) return null;
+ return { start: left, end: right };
+}
+
+let layout: NativeWindowLayout | null = null;
+let listening = false;
+const listeners = new Set<() => void>();
+
+function publish(next: NativeWindowLayout): void {
+ layout = next;
+ for (const listener of listeners) listener();
+}
+
+/** Starts following the window layout the Android shell reports (feature windowLayout), once. */
+function follow(): void {
+ if (listening || !hasNativeFeature('windowLayout')) return;
+ listening = true;
+ TrekShell.getWindowLayout().then(publish, reportNativeFailure);
+ TrekShell.addListener('windowLayout', publish).catch(reportNativeFailure);
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+const current = () => layout;
+
+/**
+ * The fold of the window, while the app's shell reports one (Android, feature
+ * windowLayout); null in a browser, on iOS and on a device without one. Two-pane
+ * screens end their leading pane at it, so no row runs across the hinge.
+ */
+export function useWindowFold(): WindowFold | null {
+ const { caps } = useNativeCapabilities();
+ const supported = caps.features.includes('windowLayout');
+ useEffect(() => {
+ if (supported) follow();
+ }, [supported]);
+ const reported = useSyncExternalStore(subscribe, current, current);
+ return supported ? foldOf(reported) : null;
+}
+
+/** Test helper: forget the layout and the subscription. */
+export function _resetWindowFold(): void {
+ layout = null;
+ listening = false;
+ listeners.clear();
+}
diff --git a/client/tailwind.config.js b/client/tailwind.config.js
index 63d5e0af8..737f2f4a9 100644
--- a/client/tailwind.config.js
+++ b/client/tailwind.config.js
@@ -127,7 +127,7 @@ export default {
// responsive class.
({ addVariant }) => {
addVariant('wide', '@media (min-width: 600px) and (min-height: 501px)')
- addVariant('split', '@media (min-width: 900px) and (min-height: 501px)')
+ addVariant('split', '@media (min-width: 840px) and (min-height: 501px)')
},
],
}
diff --git a/client/tests/unit/mobile/MobileShell.test.tsx b/client/tests/unit/mobile/MobileShell.test.tsx
index a996752b6..ba9ac3462 100644
--- a/client/tests/unit/mobile/MobileShell.test.tsx
+++ b/client/tests/unit/mobile/MobileShell.test.tsx
@@ -7,7 +7,11 @@ import { usePluginStore } from '../../../src/store/pluginStore';
// FE-MOB-MSHELL-001 onwards
+const fold = vi.hoisted(() => ({ current: null as null | { start: number; end: number } }));
+vi.mock('../../../src/native/windowFold', () => ({ useWindowFold: () => fold.current }));
+
beforeEach(() => {
+ fold.current = null;
useAddonStore.setState({ addons: [], loaded: true });
usePluginStore.setState({ plugins: [], loaded: true });
});
@@ -117,4 +121,20 @@ describe('MobileShell', () => {
expect(content.className).toContain('overflow-y-auto');
expect(content.className).toContain('md:overflow-visible');
});
+
+ it('FE-MOB-MSHELL-008: a fold the Android app reports is where the panes meet', () => {
+ const { container, rerender } = render(
+ page body
,
+ { initialEntries: ['/dashboard'] },
+ );
+ const root = () => container.querySelector('.m-root') as HTMLElement;
+ expect(root().dataset.fold).toBeUndefined();
+ expect(root().style.getPropertyValue('--m-fold-start')).toBe('');
+
+ fold.current = { start: 420, end: 421 };
+ rerender(page body
);
+ expect(root().dataset.fold).toBe('vertical');
+ expect(root().style.getPropertyValue('--m-fold-start')).toBe('420px');
+ expect(root().style.getPropertyValue('--m-fold-end')).toBe('421px');
+ });
});
diff --git a/client/tests/unit/mobile/keyboard.test.tsx b/client/tests/unit/mobile/keyboard.test.tsx
index 0cb0186af..6e34b26cd 100644
--- a/client/tests/unit/mobile/keyboard.test.tsx
+++ b/client/tests/unit/mobile/keyboard.test.tsx
@@ -193,9 +193,37 @@ describe('MKeyboardHost', () => {
});
});
+describe('MKeyboardHost in the Android app', () => {
+ beforeEach(() => {
+ native.ios = false;
+ native.features = [];
+ native.setKeyCommands.mockClear();
+ useShortcutRegistry.setState({ lists: [] });
+ });
+
+ it('FE-MOB-KEYS-010: an Android shell with key commands takes the shortcuts too, the feature decides', async () => {
+ native.features = ['keyCommands'];
+ const run = vi.fn();
+ render(
+ <>
+
+
+ >
+ );
+ await waitFor(() =>
+ expect(native.setKeyCommands).toHaveBeenLastCalledWith({
+ commands: [{ id: 'trip:tab-plan', title: 'Plan', input: '1', modifiers: ['command'] }],
+ })
+ );
+ fireEvent.keyDown(document.body, { key: '1', metaKey: true });
+ expect(run).not.toHaveBeenCalled();
+ });
+});
+
describe('MShellShortcuts', () => {
beforeEach(() => {
native.ios = false;
+ native.features = [];
useShortcutRegistry.setState({ lists: [] });
usePluginStore.setState({ plugins: [], loaded: true });
useAddonStore.setState({
diff --git a/client/tests/unit/mobile/sizeClass.test.ts b/client/tests/unit/mobile/sizeClass.test.ts
index e65f1b44b..d5a74d94c 100644
--- a/client/tests/unit/mobile/sizeClass.test.ts
+++ b/client/tests/unit/mobile/sizeClass.test.ts
@@ -36,6 +36,9 @@ describe('size classes', () => {
ipad13SplitHalf: [683, 1032, true, false],
duoUnfoldedLandscape: [1017, 699, true, true],
duoUnfoldedPortrait: [699, 1017, true, false],
+ ipad11Portrait: [834, 1194, true, false],
+ androidFoldUnfoldedLandscape: [841, 701, true, true],
+ androidFoldUnfoldedPortrait: [701, 841, true, false],
};
for (const [name, [w, h, wide, split]] of Object.entries(sizes)) {
expect([name, evaluate(WIDE_QUERY, w, h)]).toEqual([name, wide]);