Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions apps/mobile/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w
- **Tabs** remain **Chats** and **Activity**, using the same Expo Router JS tab navigator on all platforms, with the existing route stacks above it. Search arrives with its working N3 slice.
- A chat or creation form is pushed above the tabs, hiding the bar. Each list reserves the measured bar height plus safe-area gap; scroll hide/reveal stays on the UI thread.
- The floating navigation island sits beside a separate circular **+**, equal-height at default text size. At larger text sizes, the island can grow; on iOS each tab label stays on one line and shrinks to fit (`navigationBar.labelMinScale`) instead of breaking inside a word; the icon-only circle keeps its normal touch size and aligns to the bottom so it does not consume the labels' width. iOS uses real SwiftUI navigation and plus/close buttons. On iOS 26 the selected tab and circular action use native glass button styles over the shared glass island; earlier iOS uses bordered controls. System Reduce Transparency remains authoritative. Native text keeps semantic Dynamic Type. This is app-owned tab presentation, not `NativeTabs` or a second navigation stack (ADR-0235).
- Android retains the opaque OCAT raised rounded rectangle, hairline edge, faint token shadow and selected icon tile. The Activity destination shows the bell **or** the capped pending count, never both, on iOS and Android alike: when Activity is not selected and something is pending, the count replaces the bell; when nothing is pending, or Activity is selected (its feed already shows the items), the bell returns. Android cross-fades the swap. The accessible label always carries the count. Essential labels grow with system text.
- **+** opens a modal action panel in the island position, with close in the circular position. Back, accessibility escape and backdrop only dismiss it. The panel is always a scrollable vertical menu: each full-width row has a leading icon and left-aligned wrapping label. Target choices use the same row treatment. On iOS 26 the panel uses a native rounded-rectangle glass backdrop, including at larger text sizes; Reduce Transparency and older iOS keep an opaque raised surface. This panel opt-in does not change the existing multiline drawer-title fallback. Android retains its opaque OCAT surface. The panel still uses the existing modal fade, not a native glass morph transition.
- Android uses the Material 3 flexible navigation bar instead of the island: docked full width at the bottom, edge to edge under the system navigation bar, 64dp tall (`materialNavigationBar`), on the `raised` surface with level-2 elevation and no corners. Each destination is a 56×32dp full-pill indicator in `raised.chosen` when selected, a 24dp icon, 4dp gap and a 12sp label (strong when selected), with a borderless ripple. Expo UI 58's Compose `NavigationBar` only wraps the baseline 80dp bar, which Material 3 marks not recommended, so this is drawn to the m3.material.io token values. The bar grows with system text and hides on scroll like the island. The Activity destination shows the bell **or** the capped pending count, never both, on iOS and Android alike: when Activity is not selected and something is pending, the count replaces the bell; when nothing is pending, or Activity is selected (its feed already shows the items), the bell returns. Android cross-fades the swap. The accessible label always carries the count. Essential labels grow with system text.
- **+** opens a modal action panel in the island position, with close in the circular position. Back, accessibility escape and backdrop only dismiss it. The panel is always a scrollable vertical menu: each full-width row has a leading icon and left-aligned wrapping label. Target choices use the same row treatment. On iOS 26 the panel uses a native rounded-rectangle glass backdrop, including at larger text sizes; Reduce Transparency and older iOS keep an opaque raised surface. This panel opt-in does not change the existing multiline drawer-title fallback. The panel still uses the existing modal fade, not a native glass morph transition.
- On Android, creation is a Material 3 **FAB** on Chats only: 56dp, 16dp corners, `canvas.chosen` (primary container), level-3 elevation, 16dp from the trailing edge and 16dp above the navigation bar. It opens a **FAB menu** (m3.material.io FAB menu tokens, `fab`): the FAB becomes a 56dp circular close button in the accent colour, and the actions stack above it, trailing-aligned, as full-pill items 56dp tall, 4dp apart, hugging their icon and label with 24dp side padding. Target choices and Back use the same items under a notice; errors show in that notice. The items scroll behind the close button when space runs out. Back, accessibility escape and the scrim dismiss it. Expo UI 58 has no Compose FAB menu, so it is drawn to the token values.
- Computer: new Channel/new Bot. Owner Organization: new Channel/invite. All: new Channel/new Bot/join with link or QR, followed by explicit destination choice where needed. Member/guest workspaces do not gain owner controls. Pairing and Organization administration stay in the drawer.
- Creation binds the chosen Host ID or cloud account scope plus Organization ID. Cloud rechecks current identity and ownership before opening/submitting; stale scope refuses. Existing Host full-client and CloudClient idempotent mutations remain the business owners. Dismissal never starts a background operation.

Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/ui/design/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ export const navigationBar = {
dark: '0 2px 8px rgba(0, 0, 0, 0.12)',
},
} as const;
export const materialNavigationBar = {
height: 64,
indicatorWidth: 56,
indicatorHeight: 32,
iconLabelGap: 4,
elevation: 3,
} as const;
export const fab = {
size: 56,
radius: 16,
margin: 16,
elevation: 6,
menuItemHeight: 56,
menuItemGap: 4,
menuItemPadding: 24,
menuIconGap: 8,
menuCloseGap: 8,
} as const;
export const materialDrawer = { width: 360, scrimGutter: 56, addressMinScale: 0.6 } as const;
export const loadingIndicator = { size: 48 } as const;
export const swipeAction = { size: 56 } as const;
Expand Down
172 changes: 125 additions & 47 deletions apps/mobile/src/ui/shell/bottom-navigation.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { BottomTabBar, Tabs } from 'expo-router/js-tabs';
import { router } from 'expo-router';
import { useRef, useState, type ComponentProps } from 'react';
import { Keyboard, View, useWindowDimensions, type ViewInstance } from 'react-native';
import {
Keyboard,
Platform,
Pressable,
View,
useWindowDimensions,
type ViewInstance,
} from 'react-native';
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { hostSession, useHostSession } from '../../host/host-session';
Expand All @@ -20,12 +27,13 @@ import { cloudError } from '../../cloud/client';
import { ClientError } from '../../cloud/instance-contract';
import { prepareCloudCreation } from '../../cloud/prepare-creation';
import { shownHostName } from '../design/host-look';
import { useDesignTheme } from '../design/theme';
import { navigationBar, radius, space } from '../design/tokens';
import { pressTint, useDesignTheme } from '../design/theme';
import { fab, materialNavigationBar, navigationBar, radius, space } from '../design/tokens';
import { useMounted } from '../mounted';
import { NavigationMaterial } from './navigation-material';
import { NavigationDestinations } from './navigation-destinations';
import { NavigationAction } from './navigation-action';
import { PlusIcon } from './icons';
import { CreationPanel } from './creation-panel';
import { FloatingTabInset } from './floating-tab-inset';
import { ScrollChromeProvider, useChromeHidden, useChromeProgress } from './scroll-chrome';
Expand All @@ -45,11 +53,22 @@ function NavigationLayout() {
const theme = useDesignTheme();
const insets = useSafeAreaInsets();
const { fontScale } = useWindowDimensions();
const size = Math.max(
navigationBar.height,
theme.type.title.line + theme.type.meta.line * fontScale * (fontScale > 1.3 ? 2 : 1) + space.x5,
);
const bottom = Math.max(insets.bottom, space.x4);
const android = Platform.OS === 'android';
const size = android
? Math.max(
materialNavigationBar.height,
materialNavigationBar.indicatorHeight +
materialNavigationBar.iconLabelGap +
theme.type.meta.line * fontScale +
space.x4,
)
: Math.max(
navigationBar.height,
theme.type.title.line +
theme.type.meta.line * fontScale * (fontScale > 1.3 ? 2 : 1) +
space.x5,
);
const bottom = android ? insets.bottom : Math.max(insets.bottom, space.x4);
const reserved = size + bottom + space.x5;
const quickActions = useQuickActionRouting();
return (
Expand Down Expand Up @@ -93,6 +112,7 @@ function ScopedNavigationBar({
saved ? { hostId: saved.hostId, name: shownHostName(look, saved.hostName) } : undefined,
directory.workspaces,
);
const theme = useDesignTheme();
const pending = useActivityCount().count ?? 0;
const insets = useSafeAreaInsets();
const [menu, setMenu] = useState<string>();
Expand Down Expand Up @@ -156,6 +176,100 @@ function ScopedNavigationBar({
setBusy(false);
}
};
const items = state.routes.map((route, index) => ({
key: route.key,
focused: state.index === index,
activity: route.name === '(activity)',
count: route.name === '(activity)' ? pending : 0,
onPress: () => {
const event = emitter.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (state.index !== index && !event.defaultPrevented) navigateToTab(route.key);
},
onLongPress: () => emitter.emit({ type: 'tabLongPress', target: route.key }),
}));
const create = () => {
Keyboard.dismiss();
setError(undefined);
setMenu(scope);
};
const fabBottom = size + bottom + fab.margin;
const panel = menu === scope && (
<CreationPanel
key={scope}
scope={scope}
targets={targets}
size={navigationBar.height}
bottom={Platform.OS === 'android' ? fabBottom : bottom}
onClose={close}
onPick={(action, target) => void choose(action, target)}
busy={busy}
error={error}
/>
);
if (Platform.OS === 'android')
return (
<>
<Animated.View
ref={mount}
pointerEvents={hidden ? 'none' : 'box-none'}
accessibilityElementsHidden={hidden}
importantForAccessibility={hidden ? 'no-hide-descendants' : 'auto'}
style={[
{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
minHeight: size + bottom,
paddingBottom: bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
flexDirection: 'row',
backgroundColor: theme.layer.raised.fill,
elevation: materialNavigationBar.elevation,
},
style,
]}
>
<NavigationDestinations items={items} />
</Animated.View>
{state.routes[state.index]?.name === '(home)' ? (
<Animated.View
pointerEvents={hidden ? 'none' : 'box-none'}
accessibilityElementsHidden={hidden}
importantForAccessibility={hidden ? 'no-hide-descendants' : 'auto'}
style={[
{ position: 'absolute', right: insets.right + fab.margin, bottom: fabBottom },
style,
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={t.createHere}
onPress={create}
android_ripple={{ color: pressTint(theme.color.fg) }}
style={{
width: fab.size,
height: fab.size,
borderRadius: fab.radius,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.layer.canvas.chosen,
elevation: fab.elevation,
}}
>
<PlusIcon color={theme.color.fg} size={theme.type.title.line} />
</Pressable>
</Animated.View>
) : null}
{panel}
</>
);
return (
<>
<Animated.View
Expand Down Expand Up @@ -186,47 +300,11 @@ function ScopedNavigationBar({
}}
>
<NavigationMaterial />
<NavigationDestinations
items={state.routes.map((route, index) => ({
key: route.key,
focused: state.index === index,
activity: route.name === '(activity)',
count: route.name === '(activity)' ? pending : 0,
onPress: () => {
const event = emitter.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (state.index !== index && !event.defaultPrevented) navigateToTab(route.key);
},
onLongPress: () => emitter.emit({ type: 'tabLongPress', target: route.key }),
}))}
/>
<NavigationDestinations items={items} />
</View>
<NavigationAction
size={navigationBar.height}
label={t.createHere}
onPress={() => {
Keyboard.dismiss();
setError(undefined);
setMenu(scope);
}}
/>
<NavigationAction size={navigationBar.height} label={t.createHere} onPress={create} />
</Animated.View>
{menu === scope && (
<CreationPanel
key={scope}
scope={scope}
targets={targets}
size={navigationBar.height}
bottom={bottom}
onClose={close}
onPick={(action, target) => void choose(action, target)}
busy={busy}
error={error}
/>
)}
{panel}
</>
);
}
45 changes: 45 additions & 0 deletions apps/mobile/src/ui/shell/creation-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useState } from 'react';
import {
creationActions,
targetsFor,
type CreationAction,
type CreationTarget,
} from '../../host/creation-targets';
import { cloudAvailable } from '../../cloud/available';
import { t } from '../strings';

export const creationLabel = (action: CreationAction) =>
({
channel: t.newChannel,
bot: t.newPersonaBot,
invite: t.invitePeople,
join: t.cloudJoin.title,
})[action];

export type CreationPanelProps = {
scope: string;
targets: CreationTarget[];
bottom: number;
size: number;
onClose(): void;
onPick(action: CreationAction, target?: CreationTarget): void;
busy: boolean;
error?: string | undefined;
};

export function useCreationMenu({
scope,
targets,
onPick,
}: Pick<CreationPanelProps, 'scope' | 'targets' | 'onPick'>) {
const [choosing, setChoosing] = useState<CreationAction>();
const actions = creationActions(scope, targets).filter((a) => cloudAvailable || a !== 'join');
const choices = choosing ? targetsFor(choosing, scope, targets) : [];
const pick = (action: CreationAction) => {
if (action === 'join') return onPick(action);
if (scope === 'all') return setChoosing(action);
const target = targetsFor(action, scope, targets)[0];
if (target) onPick(action, target);
};
return { choosing, setChoosing, actions, choices, pick };
}
Loading
Loading