From 6906f80ff8ac78929d8bcfe5bd0c15524044ee82 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 17:27:19 +0100 Subject: [PATCH] feat(native): Android tablets get the shortcuts, two panes meet at a foldable's fold The Android shell now reports keyCommands and windowLayout, so the large screen layout no longer assumes an iPad: - The keyboard host hands the shortcuts to the app whenever the shell has the keyCommands feature, whatever the platform. Before it also required iOS, so an Android tablet with a keyboard answered the keys in the page and never listed them in the system's shortcuts. - useWindowFold follows the layout the Android shell reports (feature windowLayout). When a vertical fold separates the window, a foldable half open like a book, MobileShell marks it with data-fold and its place, and the leading pane (the trip's plan, a tab's summary, a list beside its detail) ends at the fold while the trailing one starts there, on either reading direction. A flat fold that does not separate stays one screen. - The split size class starts at 840pt, Android's expanded width, instead of 900pt, so a foldable unfolded on its side (about 841dp) gets two panes like an iPad does. An 11" iPad upright (834pt) stays a single pane. The address screen of the native shell follows the same width. - TrekShell exports NativeWindowLayout for it. Also: the sheet's width and anchor types and the collab board type stay private to their modules, which nothing else imports (knip). --- client/native-shell/shell.css | 2 +- client/src/hooks/sizeClass.ts | 12 ++- client/src/mobile/MobileShell.tsx | 11 ++- client/src/mobile/components/MSheet.tsx | 4 +- client/src/mobile/keyboard/MKeyboardHost.tsx | 11 +-- client/src/mobile/mobile.css | 18 ++++ .../mobile/screens/trip/tabs/collabPanes.ts | 2 +- client/src/native/trekShell.ts | 2 +- client/src/native/windowFold.test.ts | 85 +++++++++++++++++++ client/src/native/windowFold.ts | 72 ++++++++++++++++ client/tailwind.config.js | 2 +- client/tests/unit/mobile/MobileShell.test.tsx | 20 +++++ client/tests/unit/mobile/keyboard.test.tsx | 28 ++++++ client/tests/unit/mobile/sizeClass.test.ts | 3 + 14 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 client/src/native/windowFold.test.ts create mode 100644 client/src/native/windowFold.ts 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]);