diff --git a/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-dark.png b/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-dark.png new file mode 100644 index 0000000000..c7cc2dfb99 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-light.png b/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-light.png new file mode 100644 index 0000000000..bfa61b7e1c Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-1-chats-ax5-light.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-1-chats-dark.png b/.github/pr-screenshots/2333-ios-hig/after-1-chats-dark.png new file mode 100644 index 0000000000..3a3599e1e4 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-1-chats-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-1-chats-light.png b/.github/pr-screenshots/2333-ios-hig/after-1-chats-light.png new file mode 100644 index 0000000000..5c926426fc Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-1-chats-light.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-dark.png b/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-dark.png new file mode 100644 index 0000000000..0d8504e044 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-light.png b/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-light.png new file mode 100644 index 0000000000..a7692dac1b Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-10-activity-selected-no-pending-light.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-2-search-dark.png b/.github/pr-screenshots/2333-ios-hig/after-2-search-dark.png new file mode 100644 index 0000000000..2ff3f26ce4 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-2-search-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-2-search-light.png b/.github/pr-screenshots/2333-ios-hig/after-2-search-light.png new file mode 100644 index 0000000000..eff41949e1 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-2-search-light.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-dark.png b/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-dark.png new file mode 100644 index 0000000000..9ee57d9c36 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-light.png b/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-light.png new file mode 100644 index 0000000000..6b5c87494a Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-3-sidebar-light.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-dark.png b/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-dark.png new file mode 100644 index 0000000000..73e9e01ba1 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-light.png b/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-light.png new file mode 100644 index 0000000000..772e008fa4 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-4-host-menu-light.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-5-unpair-dark.png b/.github/pr-screenshots/2333-ios-hig/after-5-unpair-dark.png new file mode 100644 index 0000000000..86f56f705c Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-5-unpair-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-5-unpair-light.png b/.github/pr-screenshots/2333-ios-hig/after-5-unpair-light.png new file mode 100644 index 0000000000..d12297b133 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-5-unpair-light.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-dark.png b/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-dark.png new file mode 100644 index 0000000000..d06e5d1f6c Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-light.png b/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-light.png new file mode 100644 index 0000000000..84d5533aa8 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-6-member-menu-light.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-dark.png b/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-dark.png new file mode 100644 index 0000000000..50a813c71f Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-light.png b/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-light.png new file mode 100644 index 0000000000..b071f99a3a Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-7-dm-scrolled-light.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-dark.png b/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-dark.png new file mode 100644 index 0000000000..07ebfd8d93 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-light.png b/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-light.png new file mode 100644 index 0000000000..f02560367e Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-8-pairing-sheet-light.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-dark.png b/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-dark.png new file mode 100644 index 0000000000..0ffa4f82f2 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-dark.png differ diff --git a/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-light.png b/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-light.png new file mode 100644 index 0000000000..1401086715 Binary files /dev/null and b/.github/pr-screenshots/2333-ios-hig/after-9-activity-unselected-no-pending-light.png differ diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index 6a68b98c3e..5b2b9dabcc 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -19,13 +19,12 @@ References: On both platforms the system's own design and components are the standard: Apple's Human Interface Guidelines on iOS (`ios-hig` skill), Material 3 on Android (`android-material` skill). Where the platform has a component for the job, use it (native navigation, `@expo/ui` SwiftUI views on iOS, `@expo/ui` Jetpack Compose views on Android) and keep its default look and behaviour; do not draw a React Native look-alike or restyle it into a house style. A custom surface is allowed only where no native component fits, and its reason is written in this file first. -Older rules below that prescribe a custom surface where a native component exists (for example the OCAT-inspired Android choice menus, or iOS menus shown as alerts) describe the current implementation, not the target. Change them to the native component when the screen is next touched, and update the rule in the same change. +Older rules below that prescribe a custom surface where a native component exists describe the current implementation, not the target. Change them to the native component when the screen is next touched, and update the rule in the same change. -## Android application dialogs +## Application dialogs -- Application-owned menus and confirmations use `showAppDialog` and the root `AppDialogHost`, matching the accepted OCAT-inspired Settings surfaces. Do not call React Native `Alert.alert` directly from a screen or feature. On iOS `showAppDialog` shows the system alert and is for confirmations only; iOS menus follow [iOS menus and native controls](#ios-menus-and-native-controls). Operating-system permission prompts, photo pickers and the share sheet remain system-owned. -- Use `theme.overlay.fill`, the same `androidSettings.radius` as Settings groups, the shared scrim, semantic text and existing Button/SettingsRow primitives. Keep the panel centered with safe-area gutters and a bounded width; do not stretch a short confirmation to fill the screen or add heavy shadows. Contents scroll at large system text sizes; confirmation actions stack when horizontal space is limited. Never cap font scaling or truncate the consequence text. -- Choice menus show separate filled tonal buttons for same-priority actions, with icons, visible containers and pressed/ripple feedback. Use the shared accent container and readable on-container label; do not leave key actions as unbounded text. Omit the redundant Cancel button in these menus: backdrop, Back and accessibility escape dismiss without choosing. Use colors rather than button outlines. Destructive menu choices use the shared danger fill and open a second confirmation describing the consequence, with a low-emphasis explicit Cancel and a destructive action. Keep the current drawer/page underneath. This adapts the [Android button hierarchy](https://developer.android.com/develop/ui/compose/quick-guides/content/create-button) to our OCAT-inspired app surfaces; a menu's equivalent actions are not all primary final confirmations. +- Application-owned confirmations and choice menus use `showAppDialog` (or `confirmDestructive` / `showActionMenu`) and the root `AppDialogHost`. Do not call React Native `Alert.alert` directly from a screen or feature. Per Native first, iOS shows system alerts (menus follow [iOS menus and native controls](#ios-menus-and-native-controls)), and Android shows the Material 3 `AlertDialog` from `@expo/ui/jetpack-compose`, themed by `seedColor` = accent. Operating-system permission prompts, photo pickers and the share sheet remain system-owned. +- A confirmation puts Cancel in the dialog's dismiss slot and the action in its confirm slot as text buttons; a destructive action's label uses `danger`. A menu with no anchor (a long press, `showActionMenu`) is never a dialog: on Android it is a Material 3 modal bottom sheet listing its title and one `ListItem` per command, a divider before the destructive command in `danger`, dismissed by Back, scrim or swipe. A menu from a ⋯ button is an anchored dropdown (`ActionMenuButton`), with the same divider before its destructive item. A confirmation always states the consequence in its message, not only a title. Destructive menu choices open a second confirmation describing the consequence. Back and a tap outside dismiss without choosing. The system dialog handles width, scrolling at large text sizes, focus and TalkBack. - While an asynchronous action is pending, disable repeat submission and dismissal; close after success. On failure, keep the prompt and retry available. A menu opening a confirmation must not dismiss the newer prompt when its old callback finishes. Dialog requests are transient UI state, never a second persistence or authorization owner. - Native checks include cancel/button/Back/backdrop paths, nested confirmation, light/dark and default/intermediate/maximum text. Verify the original pairing/group remains unchanged after cancellation, keyboard dismissal from focused search, and the actual rendering above an open drawer or profile. Record iOS and TalkBack qualification separately. @@ -39,7 +38,7 @@ This applies [Native first](#native-first) to iOS menus, following the Apple HIG - **Exceptions stay as recorded.** The chat bubble menu (reaction island and action card, see Chat) and app-owned tab presentation (ADR-0235) are deliberate custom surfaces. Any new custom surface where the system has a control needs the reason written here first. - Native menus inherit Liquid Glass, Dynamic Type, VoiceOver and Reduce Transparency from the system; do not restyle them. Every action in a context menu is also reachable from a visible control or an accessibility action. -Current iOS gaps: the Host drawer's ⋯ menu (`src/ui/shell/host-drawer.tsx`) and the group member ⋯ menu (`src/ui/chat/group-info.tsx`) still open a system alert listing their actions; both move to native pull-down menus. +Build a ⋯ menu with `ActionMenuButton` (`src/ui/primitives/action-menu-button`): a SwiftUI pull-down `Menu` with SF Symbols on iOS and a Material 3 `DropdownMenu` on Android, both anchored to the button. The Host drawer and the group member ⋯ menus use it. Long press on a row still opens `showActionMenu` (an action sheet on iOS). ## Activity query controls @@ -73,10 +72,11 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Banner: the accent band (default), a solid colour (ten swatches or a typed hex), a two-stop gradient (six presets or typed stops), or a picture under a dark scrim. - A custom colour goes through the accent band's steps, so dark mode mutes it the same way. The ink is white or near-black, whichever reads on every stop; a picture always takes white ink with a soft shadow. - **Workspace drawer** switches between paired Computers and Cloud organization workspaces. It slides in from the left, covers about 85% of the width and dims the list behind it. + - On iOS 26, unless Reduce Transparency is on, it is a floating Liquid Glass sidebar: inset `sidebar.inset` from the screen's left, top and bottom edges with `sidebar.radius` corners concentric with the display. Its rows are clear; a pressed row and the current Computer or workspace show a `radius.lg` highlight inset from the sidebar's edges, as iOS sidebars do. The pairing card is frosted. A Computer's ⋯ opens a native pull-down menu anchored to it (Customize, Show this computer, Unpair); a long press on the row still opens the action sheet. Older iOS, Reduce Transparency and Android keep the opaque full-height drawer. - Rounded **Computers / Cloud** tabs are the first row above one scrolling list, with no duplicate title or close button. Opening the drawer selects the current route's tab; switching tabs only changes the list, never the active computer or cloud session. Android uses opaque raised chips; iOS uses the existing glass material with Reduce Transparency, older OS and large-text fallbacks. Tabs expose their selected state to accessibility. - One contextual plus action sits directly below the tabs, above the scrolling list: Computers opens **Pair a new computer** directly; Cloud opens **Join by link or QR** directly. There is no mixed action chooser or stacked icon group. Organization creation and service configuration live under **Manage cloud services** on the Cloud tab; Notifications stays at the bottom of a fixed footer on both tabs; Cloud service management sits above it. Only the directory scrolls. - A cloud service is an instance/address; its organization is a cloud workspace; a channel belongs to that organization. An invitation already identifies its service and scope: workspace membership or access to only one channel. The existing preview and acceptance flow keeps this distinction explicit. No separate server-selection step or new authorization store is introduced. - - Each Host row has its Computer avatar tile (the same tile as the Host chip; the current Host's tile has an `fg` ring at radius `lg`, concentric with the tile, as Slack marks the current workspace), its name, an online/offline/checking dot and label, then one **count chip** on the right (see Count chip below). It shows **Needs you** when anything waits for the Human, because that matters most; otherwise it shows **Unread**, the count of unread conversations. A ⋯ button at the end, or a long press on the row, opens the Host's menu: Customize (name, avatar and banner), Show this computer, and Unpair, which asks for confirmation. + - Each Host row has its Computer avatar tile (the same tile as the Host chip; the current Host's tile has an `fg` ring at radius `lg`, concentric with the tile, as Slack marks the current workspace), its name, an online/offline/checking dot and label, then one **count chip** on the right (see Count chip below). It shows **Needs you** when anything waits for the Human, because that matters most; otherwise it shows **Unread**, the count of unread conversations. A ⋯ button at the end, or a long press on the row, opens the Host's menu: Customize (name, avatar and banner), Show this computer, and Unpair, which asks for confirmation. The ⋯ menu is the system's own anchored menu: a SwiftUI pull-down on iOS and a Material 3 dropdown on Android, with Unpair in the danger colour. - The Host chip opens it, and so does a swipe right from the left edge of a tab's root screen. The edge swipe is never attached inside a pushed chat, where it stays the system Back. - Tapping a Host switches to it and closes the drawer. Tapping the backdrop, swiping left or using the accessibility escape gesture closes it. Opening focuses the selected tab. - It takes screen-reader focus when it opens and returns focus to the Host chip when it closes. With Reduce Motion on it appears without sliding. @@ -85,8 +85,8 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Pairing accepts an optional local name using the existing computer appearance store; direct-link pairing can keep the original name, then rename in Customize computer. Cloud import accepts a display name; Manage cloud services can rename it later. These names affect only this phone and never change Host identity, organization names, trusted endpoints or sessions. - **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 and its labels wrap; 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. An inactive Activity destination replaces its icon with the capped pending count; the count owner and selected Activity behavior are unchanged. iOS shows the same scoped count beside its icon. Essential labels grow with system text. + - 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. - 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. @@ -95,7 +95,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - Conversation rows keep the title and timestamp on one horizontal line at every system text size. The title takes the remaining width and truncates with tail ellipsis; the timestamp keeps its own single-line slot. Preview/status and count stay on a second line. A conversation without a timestamp does not reserve an empty metadata line. Preserve native font scaling and the complete accessible title/preview. - The header has a compact Host title on the left and a Settings gear on the right, on both iOS and Android. This row stays horizontal at every system text size: the Host chip shrinks, its name occupies one line with tail ellipsis, and the Settings target stays at the trailing edge. Keep native font scaling and the full accessible Host name; both targets retain the platform minimum touch size. Other Host-name presentations may wrap. The Host picker stays mounted between visits; its translation follows a critically damped UI-thread spring and its scrim is clamped. Hidden contents leave the accessibility tree. - **+** sits at the right of the channel search field. It opens the native **New PersonaBot** / **New Channel** menu and uses the app's create flow, or the full client where the app has none yet. - - A **search field** at the top of the list panel, inset so its left edge lines up with the row avatars (`AVATAR_EDGE`), with corners concentric with the panel's rounded top: the field uses `radius.md` and the panel `radius.md + AVATAR_EDGE`. It scrolls away with the list. There are no shortcut cards: Activity already gathers what needs the Human. + - A **search field** floats just above the bottom navigation island, within reach of the thumb, as iOS 26 places search. It is a capsule on the shared navigation glass (`DrawerMaterial`; opaque raised on older iOS, Reduce Transparency and Android) inset like the island, rides above the keyboard while typing, and hides and returns with the navigation on scroll. While it is focused or holds a query the navigation stays visible, and a circular close button beside it (the island's glass circle) clears the query and dismisses the keyboard. The field is a native UIKit text field with the system clear button, not a SwiftUI field, which has none. The list reserves the field's height under its last row. There are no shortcut cards: Activity already gathers what needs the Human. - One list of PersonaBot DMs and Channels together, grouped by the Host's **Channel sections** as on the desktop sidebar: - Sections come in the desktop's order. Ungrouped DMs and Channels sit between them in the desktop's top-level order. - A section header shows its name and Channel count and collapses or expands on tap. A collapsed section still shows a "Needs you" mark when something inside needs the Human. The collapse state is kept on the phone, per Host. @@ -115,7 +115,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - A row's title is `category · Bot`; a reply keeps its text on a second line, and a handled item collapses to one line: `category · Bot · summary`. - Opening an item jumps to the card in its chat; an unread conversation opens its DM or group chat. - A floating header follows the chat chrome: title on the left, sorting, filtering and Activity search on the right. Filters live in a sheet, with category, Bot, Channel, unread-message and clear controls; no permanent filter chips. A nonblank Activity search hides sections with no matches; an entirely empty result shows one no-results message. Clearing the query restores normal empty sections. Sections with another page remain reachable. Search uses safe Activity summaries and Channel/Bot labels on the current Host before pagination. Handled history stays excluded unless selected. Default ordering is oldest action first, mentions before other personal messages, then newest unread conversation; explicit newest/oldest overrides personal-message priority. Each section can load another bounded page. Hosts without query version 1 show an update error. Complete tab totals remain a subsequent slice of [#1954](https://github.com/BotHarness/DeepSeekBot/issues/1954); when another page exists, the screen identifies counts as loaded items. -- **Settings** opens from the gear as an immediate-settings sheet: one handle, fixed header and scrolling body; swipe or activation of the handle dismisses while completed settings remain. Child pages use Back within the same sheet. Android retains its fixed accessible handle, large detent and 28dp top corners. On iOS, native modal presentation keeps nested content visible; the shared frame supplies one handle in the native vertical dismissal region, and the native header owns only the root title. The handle is also a Close button with a platform-sized touch target, explicit accessibility activation and escape; no extra X is shown. Child Back still pops only one page. Actual screen-reader focus and gestures require separate native acceptance. There is no Done/save step for immediate preferences. Settings reuses row and section components, in groups: +- **Settings** opens from the gear as an immediate-settings sheet: one handle, fixed header and scrolling body; swipe or activation of the handle dismisses while completed settings remain. Child pages use Back within the same sheet. Android retains its fixed accessible handle, large detent and 28dp top corners. On iOS, native modal presentation keeps nested content visible; the shared frame supplies one handle in the native vertical dismissal region, and the native header owns only the root title. The handle is also a Close button with a platform-sized touch target, explicit accessibility activation and escape; no extra X is shown. Child Back still pops only one page. Actual screen-reader focus and gestures require separate native acceptance. There is no Done/save step for immediate preferences. Settings reuses row and section components, in groups. Each navigable or action row leads with a system icon (`RowIcon`: an SF Symbol on iOS, a Material Symbol on Android), monochrome `fg`, or `accent`/`danger` to match the row's tone, so a row's purpose reads before its label: - **Models and presets** configures this Host's existing global default model and shared Model Preset library. Bots without an override follow that default. Presets can be created, renamed and edited for Orchestrator, Assignment default, allowed Assignment models and reasoning effort, through the existing Host methods. Editing a preset does not rewrite Bots that already applied a copy. Model selection pushes inside the same drawer, searches model/provider names and IDs, virtualizes the catalog, and keeps missing-key models visible but unavailable for selection. Saved settings and read errors are scoped to the Host and Bot/preset; cached content remains visible during refresh or failure, with retry. - Android groups use `androidSettings` tokens: 28dp corners, 16dp row padding, 56dp minimum single-line rows and 72dp minimum supporting rows that expand with system text. Section headings use the accent role, supporting text uses the body ramp and muted role, and separators are inset 16dp. Selected choices use the layer's chosen fill plus accent text and the existing checkmark. Explicit navigation rows show a chevron; destructive and copy actions do not imply navigation. Android switches use SDK 58 Expo UI's native Material 3 control inside a bounded Host; state and persistence remain owned by existing preferences. iOS retains its existing rows and switch adapter. - **Usage** comes first, as one card for the current Host, like a fitness app's week: "Usage", "Last 7 days" and a chevron, the 7-day token total in `display`, and a bar per day (`DayBars`). An idle Host shows a quiet week of stubs, and a failed read shows a danger line with Try again under the card. Tapping it pushes the **Usage** screen: @@ -161,7 +161,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - The chip is a full-round pill on a frosted `raised` (the fill at `glass.tint` opacity) with a hairline `edge`, so messages show faintly through it as they pass under. In one row it holds the Bot's avatar (no state ring), its name, and a state dot: `success` when idle, `accent` while thinking or working, `warning` when it waits for the Human, `danger` when blocked, `muted` when paused. The state is read out in the chip's accessibility label. - The chip's padding is concentric with its corners: it is `theme.touch` tall, so its corner radius is half that, and the avatar and the dot each sit that same distance from their end of the pill (padding = corner radius − the item's own radius). - Back and the computer button are round buttons of `theme.touch`, on the same frosted `raised` with a hairline `edge`, on every platform. Both icons are `fg`, never the Bot's accent. - - Messages run under the header and the status bar and fade there: a light static gradient from `base` to transparent (`edgeFade.stops`, at most 80%) covers the header and `edgeFade.top` below it, on every platform. At rest the list keeps the header's height free above the oldest message. UIKit's scroll edge effects stay off (`scrollEdgeEffects` all `hidden`). There is no live blur, because a blur view over a scrolling list re-renders every frame and needs a native module. + - Messages run under the header and the status bar and fade there: a light static gradient from `base` to transparent (`edgeFade.stops`, at most 80%) covers the header and `edgeFade.top` below it, on every platform. At rest the list keeps the header's height free above the oldest message. UIKit's scroll edge effects stay off (`scrollEdgeEffects` all `hidden`), because the header is our own. On iOS 26 with Reduce Transparency off, both fades also carry a soft scroll edge like HIG Scroll views: a `thin` system material (`EdgeMaterial`, drawn past the safe area) at full strength under the status bar, header and floating composer, fading out over `edgeFade.top` / `edgeFade.bottom` beyond them, so messages blur as well as fade and no sharp text shows through those bars. The bottom fade spans the composer's measured height plus `edgeFade.bottom`. The blur is composited by the system, so the list does not re-render. The header's back button, title capsule and trailing button sit on Liquid Glass there instead of the frosted `raised` fill. Older iOS, Reduce Transparency and Android keep the colour fade and frosted fill only. - The list runs to the bottom of the screen, and the composer floats over it with no bar of its own; its pill keeps an opaque `surface` fill, so nothing shows through where the Human types; messages pass around and under it. The bottom of the screen fades out with the same light gradient over the composer and `edgeFade.bottom` above it. The list keeps that much space below the newest message, so at rest it sits just above the composer; scrolled up, messages pass behind the composer. - Messages: - Activity rows show Bot DMs, posts elsewhere, memory commits, Bot creation, departures and stopped turns as one left-aligned meta line with a kind icon and tail ellipsis. They use the same `bubbleRow(true)` inset and single `space.x4` (12pt) top gap as a new message group, with no bottom padding or vertically centred minimum-height box: bubble → activity, activity → activity and activity → new message group keep the same boundary gap. Icons and avatars match the meta line height. In groups, the actor avatar occupies the existing speaker gutter, followed by the icon at the bubble's left edge and the description. In a Human DM, omit the current Bot's avatar and redundant name; Bot-DM actions show the other Bot's avatar after the icon. Missing avatars use the person icon. @@ -230,7 +230,7 @@ The shell is set by [ADR-0211](../../docs/adr/0211-the-mobile-app-has-two-tabs-w - The group's photo (or `#` on a rounded square), its name and member count. - A **Group** settings group: **Name** opens a bottom sheet with the name field and Save; **Choose a photo** opens the photo picker, crops the photo square and sends it to the Host scaled to 256 × 256 px as a JPEG (the Host takes at most 96 KB); **Remove photo** appears once there is one. - **Join requests** (only while one is pending): each requesting Bot with **Allow** (primary) and **Decline**. -- **Members**: **Invite a PersonaBot** first, while any roster Bot is neither a member nor invited, opening a bottom sheet of those Bots; then each member Bot with its 32 pt rounded-square avatar and "Creator" under the Bot that made the group. Tapping a member opens its profile; its ⋯ menu offers Open profile and **Remove from group**, which asks for confirmation. Pending invitations follow, marked "Invited · waiting for an answer", with **Cancel**. +- **Members**: **Invite a PersonaBot** first, while any roster Bot is neither a member nor invited, opening a bottom sheet of those Bots; then each member Bot with its 32 pt rounded-square avatar and "Creator" under the Bot that made the group. Tapping a member opens its profile; its ⋯ opens a native pull-down menu (Material dropdown on Android) with Open profile and **Remove from group**, which asks for confirmation. Pending invitations follow, marked "Invited · waiting for an answer", with **Cancel**. - **Disband group** is a danger row at the bottom; it asks for confirmation and on success returns to Chats. - A refused change shows a `danger` callout under the header and leaves the page as it was. Changes show on the phone and the desktop when the roster refreshes. - Group wake policy, group connectors and the group's work list stay on the computer. @@ -284,6 +284,8 @@ Each layer has `fill`, `pressed`, `selected` (neutral, such as the current tab), - Profile token totals retain reported usage when other calls are unknown, using the global Settings count format (`674.7K+`, for example). Completely unreported usage remains unknown; an idle, fully reported window may show zero. - `src/ui/design/` holds the tokens and theme (ADR-0201). - `src/ui/primitives/` holds `Text` (variant `meta` / `body` / `title`, and a tone that is a colour role), `Button`, `Toggle`, `SettingsRow`, `SettingsSection` and `SettingsGroupItem`, `CountBadge`, `Segmented` (an iOS-style two- or three-way switch on the layer's `inset`), `DayBars` (one bar per day or week, scaled to the busiest, optionally stacked in tones from `design/chart.ts`; an empty day keeps a stub on the layer's `inset`), `DayHeat` (a weeks × weekdays heatmap), and later `Icon`, `IconButton`, `Button`, `Avatar`, `Card`, `SettingsRow` and `EmptyState`. +- Forms use `TextField` from `primitives/text-field`: the `field` layer, a hairline `field` edge, `radius.md`, a touch-height minimum (two lines when multiline) and the system clear button on single-line fields. Give every field a placeholder or a visible label, and the keyboard and content type its value needs (`url`, `emailAddress`). `SearchField` leads with a magnifying glass and relies on the native iOS clear button; Android, which has none, adds its own. +- Follow the HIG for alerts and action sheets. An action that can't be undone (unpair, remove access, revoke, sign out, reset) asks first through `confirmDestructive`: a title that names the action, Cancel first, then the destructive button. A short list of actions on one item uses `showActionMenu`, which on iOS is an action sheet with the destructive action last and Cancel set apart; never show such a list as a centered alert. - `src/ui/chat/` will hold the conversation row, bubble, message group, time separator, action cards, composer, working status and scroll-to-bottom. - `src/ui/shell/` holds the Host drawer, Settings gear, display preferences, Channel section headers and the activity item. - `app/` routes, reads the Host client, and composes these components. diff --git a/apps/mobile/app/(tabs)/(home)/index.tsx b/apps/mobile/app/(tabs)/(home)/index.tsx index d4b5bad2cb..f26015d080 100644 --- a/apps/mobile/app/(tabs)/(home)/index.tsx +++ b/apps/mobile/app/(tabs)/(home)/index.tsx @@ -23,8 +23,9 @@ import { conversations, searchKey, type Conversation } from '../../../src/ui/cha import { CloudConversationRow } from '../../../src/ui/chat/cloud-conversation-row'; import { useConversationFilter } from '../../../src/host/native-conversation-filter'; import { ConnectionBanner } from '../../../src/ui/connection-banner'; +import { useDesignTheme } from '../../../src/ui/design/theme'; import { space } from '../../../src/ui/design/tokens'; -import { SearchField } from '../../../src/ui/primitives/search-field'; +import { BottomSearch, bottomSearchHeight } from '../../../src/ui/shell/bottom-search'; import { EnvironmentPager } from '../../../src/ui/shell/environment-pager'; import { conversationEnvironments, @@ -98,13 +99,15 @@ const HomeContent = memo(function HomeContent({ }; }, [router, client]), ); - const onScroll = useChromeScroll(); + const [query, setQuery] = useState(''); + const [searching, setSearching] = useState(false); + const onScroll = useChromeScroll(true, searching || query !== ''); const chrome = useScrollChrome(); - const bottom = useFloatingTabInset(); + const theme = useDesignTheme(); + const bottom = useFloatingTabInset() + bottomSearchHeight(theme.touch); const look = useHostLook(saved?.hostId); const cloud = useCloudSnapshot(); const directory = useCloudConversations(); - const [query, setQuery] = useState(''); const [filter, setFilter] = useConversationFilter(); const [refreshing, setRefreshing] = useState(false); const [opening, setOpening] = useState(false); @@ -300,12 +303,6 @@ const HomeContent = memo(function HomeContent({ onPairAgain={() => router.push('/pair')} /> )} - {(directory.errors.length > 0 || cloud.error || error) && ( {t.cloudListUnavailable} @@ -347,6 +344,12 @@ const HomeContent = memo(function HomeContent({ )} /> + ); diff --git a/apps/mobile/app/pair.tsx b/apps/mobile/app/pair.tsx index 6c34472ce5..88dae5b9ac 100644 --- a/apps/mobile/app/pair.tsx +++ b/apps/mobile/app/pair.tsx @@ -1,9 +1,9 @@ -import { Text, TextInput } from '../src/ui/primitives/system-text'; +import { TextField } from '../src/ui/primitives/text-field'; import { CameraView, useCameraPermissions } from 'expo-camera'; import Constants from 'expo-constants'; import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useRef, useState } from 'react'; -import { Platform, Pressable, View } from 'react-native'; +import { Platform, Pressable, View, useWindowDimensions } from 'react-native'; import { hostSession } from '../src/host/host-session'; import { hostLooks } from '../src/host/native-host-look'; @@ -16,19 +16,21 @@ import { mobileTelemetry } from '../src/telemetry/native'; import type { PairingMethod } from '../src/telemetry/schema'; import { Button } from '../src/ui/primitives/button'; import { DrawerHeader } from '../src/ui/primitives/drawer-header'; -import { useDesignTheme } from '../src/ui/design/theme'; import { KeyboardFormScroll } from '../src/ui/primitives/keyboard-form'; import { cloudAvailable } from '../src/cloud/available'; import { t } from '../src/ui/strings'; -import { useTheme } from '../src/ui/theme'; +import { useDesignTheme } from '../src/ui/design/theme'; +import { pairingScan, radius, space } from '../src/ui/design/tokens'; +import { Text } from '../src/ui/primitives/text'; function deviceName(): string { return Constants.deviceName ?? (Platform.OS === 'ios' ? 'iPhone' : 'Android phone'); } export default function PairScreen() { - const theme = useTheme(); - const design = useDesignTheme(); + const theme = useDesignTheme(); + const { width } = useWindowDimensions(); + const scan = Math.min(width * pairingScan.widthRatio, pairingScan.maxSize); const router = useRouter(); const params = useLocalSearchParams<{ p?: string }>(); const [permission, requestPermission] = useCameraPermissions(); @@ -92,60 +94,60 @@ export default function PairScreen() { router.back() } : {})} /> - {cloudAvailable && ( -