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).
This commit is contained in:
Maurice
2026-11-07 17:27:19 +01:00
parent 7083667cbf
commit 6906f80ff8
14 changed files with 255 additions and 17 deletions
+1 -1
View File
@@ -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;
+8 -4
View File
@@ -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;
+9 -2
View File
@@ -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.
<div className="m-root m-shell flex min-h-svh flex-col text-m-ink">
<div className="m-root m-shell flex min-h-svh flex-col text-m-ink" data-fold={fold ? 'vertical' : undefined} style={foldStyle}>
{/* 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
+2 -2
View File
@@ -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<MSheetVariant, string> = {
* (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<MSheetWideAnchor, string> = {
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)]',
+6 -5
View File
@@ -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) => {
+18
View File
@@ -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);
}
@@ -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 {
+1 -1
View File
@@ -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';
+85
View File
@@ -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> = {}): 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();
});
});
+72
View File
@@ -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();
}
+1 -1
View File
@@ -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)')
},
],
}
@@ -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(
<MobileShell isPhone><p>page body</p></MobileShell>,
{ 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(<MobileShell isPhone><p>page body</p></MobileShell>);
expect(root().dataset.fold).toBe('vertical');
expect(root().style.getPropertyValue('--m-fold-start')).toBe('420px');
expect(root().style.getPropertyValue('--m-fold-end')).toBe('421px');
});
});
@@ -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(
<>
<MKeyboardHost />
<Probe owner="trip" shortcuts={[{ id: 'tab-plan', title: 'Plan', key: '1', mod: true, run }]} />
</>
);
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({
@@ -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]);