diff --git a/apps/mobile/src/components/management-layout.test.tsx b/apps/mobile/src/components/management-layout.test.tsx new file mode 100644 index 0000000..a61433d --- /dev/null +++ b/apps/mobile/src/components/management-layout.test.tsx @@ -0,0 +1,29 @@ +import { expect, test } from "@jest/globals"; +import { render, screen } from "@testing-library/react-native"; +import { Dimensions, StyleSheet, Text, View } from "react-native"; +import { SettingsGroup, SettingsRow } from "./management-layout"; + +test.each([1, 1.5])( + "settings rows adapt to font scale %s without reducing touch targets", + (fontScale) => { + const originalWindow = Dimensions.get("window"); + const originalScreen = Dimensions.get("screen"); + Dimensions.set({ window: { width: 390, height: 844, scale: 3, fontScale } }); + const view = render( + + + Detailed transcript + + , + ); + expect(screen.getByRole("header", { name: "Transcript" })).toBeOnTheScreen(); + const row = screen.UNSAFE_getByType(SettingsRow).findByType(View); + expect(StyleSheet.flatten(row.props.style)).toMatchObject({ + minHeight: 48, + flexDirection: fontScale >= 1.3 ? "column" : "row", + alignItems: fontScale >= 1.3 ? "stretch" : "center", + }); + view.unmount(); + Dimensions.set({ window: originalWindow, screen: originalScreen }); + }, +); diff --git a/apps/mobile/src/components/management-layout.tsx b/apps/mobile/src/components/management-layout.tsx new file mode 100644 index 0000000..033ca63 --- /dev/null +++ b/apps/mobile/src/components/management-layout.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from "react"; +import { StyleSheet, Text, useWindowDimensions, View } from "react-native"; + +import { palette, radius, space, typography, usesLargeTextLayout } from "../theme"; + +export function SettingsGroup({ children, title }: { children: ReactNode; title: string }) { + return ( + + + {title} + + {children} + + ); +} + +export function SettingsRow({ children }: { children: ReactNode }) { + const { fontScale } = useWindowDimensions(); + return ( + + {children} + + ); +} + +export const managementStyles = StyleSheet.create({ + content: { + alignSelf: "center", + width: "100%", + maxWidth: 680, + padding: space.md, + paddingBottom: space.xl, + gap: space.md, + }, + description: { ...typography.caption, color: palette.dim }, + section: { gap: space.xs }, + sectionTitle: { ...typography.label, color: palette.dim }, + group: { + backgroundColor: palette.card, + borderRadius: radius.md, + borderColor: palette.border, + borderWidth: StyleSheet.hairlineWidth, + overflow: "hidden", + }, + row: { + alignItems: "center", + flexDirection: "row", + gap: space.sm, + minHeight: 48, + padding: space.sm, + borderBottomColor: palette.border, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + rowLargeText: { alignItems: "stretch", flexDirection: "column" }, +}); diff --git a/apps/mobile/src/components/request-card-styles.ts b/apps/mobile/src/components/request-card-styles.ts new file mode 100644 index 0000000..4603313 --- /dev/null +++ b/apps/mobile/src/components/request-card-styles.ts @@ -0,0 +1,29 @@ +import { StyleSheet } from "react-native"; +import { palette, radius, space, typography } from "../theme"; + +// Question and approval actions share visual sizing without shrinking touch targets. +export const requestCardStyles = StyleSheet.create({ + actions: { flexDirection: "row", flexWrap: "wrap", gap: space.xs }, + button: { + minHeight: 44, + paddingHorizontal: space.sm, + paddingVertical: space.xs, + borderRadius: radius.sm, + alignItems: "center", + justifyContent: "center", + borderColor: palette.border, + borderWidth: 1, + maxWidth: "100%", + }, + buttonLabel: { ...typography.control, color: palette.ink, textAlign: "center" }, + card: { + backgroundColor: palette.card, + borderColor: palette.border, + borderWidth: 1, + borderRadius: radius.md, + borderLeftWidth: 3, + borderLeftColor: palette.warm, + padding: 12, + gap: space.sm, + }, +}); diff --git a/apps/mobile/src/navigation/root-navigation.tsx b/apps/mobile/src/navigation/root-navigation.tsx index 10f7923..030c66c 100644 --- a/apps/mobile/src/navigation/root-navigation.tsx +++ b/apps/mobile/src/navigation/root-navigation.tsx @@ -153,12 +153,20 @@ export function RootNavigation() { ({ + presentation: "modal", + title: "Settings", + headerRight: () => navigation.goBack()} />, + })} /> ({ + presentation: "modal", + title: "Followed projects", + headerRight: () => navigation.goBack()} />, + })} /> void }) { + return ( + + Done + + ); +} + function WorkspaceHeader({ onBack, title, diff --git a/apps/mobile/src/screens/app-shell.tsx b/apps/mobile/src/screens/app-shell.tsx index e333a50..6a986e7 100644 --- a/apps/mobile/src/screens/app-shell.tsx +++ b/apps/mobile/src/screens/app-shell.tsx @@ -19,7 +19,7 @@ import { View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; - +import { managementStyles, SettingsGroup, SettingsRow } from "../components/management-layout"; import { ModalSheet } from "../components/modal-sheet"; import { useConnections } from "../connections/connections-context"; import type { RootStackParamList } from "../navigation/root-navigation"; @@ -367,175 +367,187 @@ export function SettingsScreen({ navigation }: ScreenProps<"Settings">) { return ( - - SETTINGS - - Device and connection controls. - + + Device and connection controls. - - - Default follow-up delivery - - Steer updates the current work. Queue waits until it finishes. - - - {(["steer", "queue"] as const).map((delivery) => ( - void transcript.update({ defaultDelivery: delivery })} - style={{ minHeight: 48, justifyContent: "center" }} - > - - {transcript.defaultDelivery === delivery ? "✓ " : ""} - {delivery === "steer" ? "Steer" : "Queue"} - - - ))} + + + + Default follow-up delivery + + Steer updates the current work. Queue waits until it finishes. + + + {(["steer", "queue"] as const).map((delivery) => ( + void transcript.update({ defaultDelivery: delivery })} + style={[ + styles.deliveryOption, + transcript.defaultDelivery === delivery && styles.deliveryOptionSelected, + ]} + > + + {transcript.defaultDelivery === delivery ? "✓ " : ""} + {delivery === "steer" ? "Steer" : "Queue"} + + + ))} + - - + - - - Detailed transcript - - {transcript.detailed - ? "Detailed: individual tool executions and system notices." - : "Compact: group activity and collapse routine details."} - - - void transcript.update({ detailed })} - /> - - - - Show reasoning - - Display model reasoning when provided by the server. - - - void transcript.update({ reasoning })} - /> - + + + Detailed transcript + + {transcript.detailed + ? "Detailed: individual tool executions and system notices." + : "Compact: group activity and collapse routine details."} + + + void transcript.update({ detailed })} + /> + + + + Show reasoning + + Display model reasoning when provided by the server. + + + void transcript.update({ reasoning })} + /> + + {transcript.error ? ( Message preferences could not be loaded or saved. Try changing the setting again. ) : null} - - - App lock - - Require device authentication after the app leaves the foreground. - - - void appLock.setEnabled(enabled)} - value={appLock.enabled} - /> - - {appLock.error ? ( - - {appLock.error} - - ) : null} - - - - Mobile notifications - - {notificationLoading - ? "Reading the shared broker setting." - : !notificationPairing - ? "Pair this connection for notifications before enabling delivery." - : notificationState?.enabled - ? "Enabled for every phone paired with this notification broker." - : notificationState - ? "Paused. New requests will not create mobile notifications." - : "The shared broker setting is unavailable."} - - - {notificationLoading ? ( - - ) : ( + + + + App lock + + Require device authentication after the app leaves the foreground. + + void setNotificationsEnabled(enabled)} - value={notificationState?.enabled ?? false} + accessibilityLabel="Require device authentication" + disabled={appLock.busy} + onValueChange={(enabled) => void appLock.setEnabled(enabled)} + value={appLock.enabled} /> - )} - + + {appLock.error ? ( + + + {appLock.error} + + + ) : null} + + + + Mobile notifications + + {notificationLoading + ? "Reading the shared broker setting." + : !notificationPairing + ? "Pair this connection for notifications before enabling delivery." + : notificationState?.enabled + ? "Enabled for every phone paired with this notification broker." + : notificationState + ? "Paused. New requests will not create mobile notifications." + : "The shared broker setting is unavailable."} + + + {notificationLoading ? ( + + ) : ( + void setNotificationsEnabled(enabled)} + value={notificationState?.enabled ?? false} + /> + )} + + {notificationError ? ( The notification broker setting could not be read or updated. ) : null} - - SESSION INBOX - Followed projects - - Choose which server projects are merged into Sessions and attention reconciliation. - - navigation.navigate("FollowedProjects")} - /> - + + + Followed projects + + Choose which server projects are merged into Sessions and attention reconciliation. + + navigation.navigate("FollowedProjects")} + /> + - - SELECTED CONNECTION - {selected?.name ?? "No connection selected"} - - {selected?.baseUrl ?? "Choose a saved server profile."} - - navigation.navigate("Connections")} - /> - + + Selected connection + {selected?.name ?? "No connection selected"} + + {selected?.baseUrl ?? "Choose a saved server profile."} + + navigation.navigate("Connections")} + /> + + - - SUPPORT - Redacted transport and transcript report - - Includes status transitions, event types, and numeric transcript performance counters. - It excludes credentials, server addresses, paths, titles, prompts, and content. - - void shareDiagnostics()} - secondary - /> - + + + Redacted transport and transcript report + + Includes status transitions, event types, and numeric transcript performance counters. + It excludes credentials, server addresses, paths, titles, prompts, and content. + + void shareDiagnostics()} + secondary + /> + + ); @@ -1038,6 +1050,21 @@ export function ActionButton({ } const styles = StyleSheet.create({ + deliveryOptions: { flexDirection: "row", flexWrap: "wrap", gap: space.xs, marginTop: space.sm }, + deliveryOption: { + minHeight: 44, + minWidth: 88, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: space.sm, + borderRadius: radius.sm, + }, + deliveryOptionSelected: { backgroundColor: palette.background }, + managementAction: { + padding: space.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: palette.border, + }, locationControls: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center" }, locationControlsLargeText: { flex: 0, width: "100%" }, sessionTabs: { @@ -1255,18 +1282,7 @@ const styles = StyleSheet.create({ width: "100%", }, stateActions: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, - settingCard: { - alignItems: "center", - backgroundColor: palette.card, - borderColor: palette.border, - borderRadius: radius.lg, - borderWidth: 1, - flexDirection: "row", - gap: space.md, - marginTop: space.lg, - padding: space.md, - }, - settingText: { flex: 1 }, + settingText: { flexGrow: 1, flexShrink: 1 }, shell: { flex: 1 }, stateCard: { alignItems: "center", diff --git a/apps/mobile/src/screens/connection-screen.tsx b/apps/mobile/src/screens/connection-screen.tsx index 440a650..c533226 100644 --- a/apps/mobile/src/screens/connection-screen.tsx +++ b/apps/mobile/src/screens/connection-screen.tsx @@ -481,20 +481,16 @@ export function ConnectionScreen({ onDone, onPair }: { onDone?: () => void; onPa > - - - OPENCODE MOBILE - + + Connections + {onDone ? ( - DONE + Done ) : null} - - Connections - Switch servers or add a direct OpenCode V2 connection. {onPair ? ( void; onPa pressed && styles.secondaryButtonPressed, ]} > - PAIR SERVER + Pair server ) : null} - SAVED SERVERS + Saved servers void; onPa pressed && styles.secondaryButtonPressed, ]} > - ADD + Add {!connections.ready ? : null} @@ -549,7 +545,7 @@ export function ConnectionScreen({ onDone, onPair }: { onDone?: () => void; onPa {profile.name} {connections.selectedProfileId === profile.id ? ( - SELECTED + ✓ Selected ) : null} @@ -569,7 +565,7 @@ export function ConnectionScreen({ onDone, onPair }: { onDone?: () => void; onPa pressed && styles.secondaryButtonPressed, ]} > - EDIT + Edit void; onPa ]} > - {pendingRemovalId === profile.id ? "CONFIRM" : "REMOVE"} + {pendingRemovalId === profile.id ? "Confirm" : "Remove"} @@ -1173,8 +1169,8 @@ const styles = StyleSheet.create({ content: { alignSelf: "center", paddingBottom: space.xl, - paddingHorizontal: space.lg, - paddingTop: space.lg, + paddingHorizontal: space.md, + paddingTop: space.sm, width: "100%", maxWidth: 680, }, @@ -1248,9 +1244,6 @@ const styles = StyleSheet.create({ minWidth: 0, }, closeButton: { - borderColor: palette.border, - borderRadius: radius.sm, - borderWidth: 1, marginLeft: space.sm, minHeight: 44, paddingHorizontal: space.md, @@ -1272,9 +1265,9 @@ const styles = StyleSheet.create({ paddingVertical: 12, }, intro: { - ...typography.body, + ...typography.caption, color: palette.dim, - marginTop: space.md, + marginTop: space.xs, maxWidth: 560, }, label: { @@ -1346,13 +1339,19 @@ const styles = StyleSheet.create({ borderColor: palette.border, borderRadius: radius.md, borderWidth: 1, - flexDirection: "row", + flexDirection: "column", overflow: "hidden", }, profileCardLargeText: { flexDirection: "column", }, - profileActions: { borderLeftColor: palette.border, borderLeftWidth: 1 }, + profileActions: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "flex-end", + borderTopColor: palette.border, + borderTopWidth: StyleSheet.hairlineWidth, + }, profileActionsLargeText: { borderLeftWidth: 0, borderTopColor: palette.border, @@ -1367,9 +1366,9 @@ const styles = StyleSheet.create({ justifyContent: "space-between", }, profileMain: { - flex: 1, + minHeight: 44, gap: 4, - padding: space.md, + padding: space.sm, }, profileName: { ...typography.heading, @@ -1382,7 +1381,8 @@ const styles = StyleSheet.create({ gap: space.sm, }, profileOrigin: { - ...typography.code, + fontFamily: typography.code.fontFamily, + ...typography.caption, color: palette.dim, }, profileSection: { @@ -1494,8 +1494,8 @@ const styles = StyleSheet.create({ textAlign: "center", }, sectionTitle: { - ...typography.heading, - color: palette.ink, + ...typography.label, + color: palette.dim, }, selectedLabel: { ...typography.label, @@ -1503,9 +1503,6 @@ const styles = StyleSheet.create({ }, smallButton: { alignItems: "center", - borderColor: palette.border, - borderRadius: radius.sm, - borderWidth: 1, justifyContent: "center", minHeight: 44, paddingHorizontal: space.sm, @@ -1568,9 +1565,9 @@ const styles = StyleSheet.create({ padding: space.lg, }, title: { - ...typography.title, + ...typography.sheetTitle, color: palette.ink, - marginTop: space.lg, + flexShrink: 1, maxWidth: 600, }, httpLabel: { diff --git a/apps/mobile/src/screens/followed-projects-screen.tsx b/apps/mobile/src/screens/followed-projects-screen.tsx index df21f0c..8315bd2 100644 --- a/apps/mobile/src/screens/followed-projects-screen.tsx +++ b/apps/mobile/src/screens/followed-projects-screen.tsx @@ -1,10 +1,10 @@ import type { NativeStackScreenProps } from "@react-navigation/native-stack"; import { useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; - +import { managementStyles } from "../components/management-layout"; import type { RootStackParamList } from "../navigation/root-navigation"; import { useWorkspaceSelection } from "../state/workspace-selection-context"; -import { control, palette, radius, space, typeRamp, typography } from "../theme"; +import { palette, radius, space, typeRamp, typography } from "../theme"; import { ShellFrame } from "./app-shell"; import { sanitizeTranscriptText } from "./session-transcript-model"; @@ -68,15 +68,10 @@ export function FollowedProjectsScreen({ navigation }: Props) { section === "Workspace" ? navigation.popTo("Workspace") : navigation.navigate(section) } > - - - FOLLOWED PROJECTS - - - Choose the projects in your session inbox. - + - Following is stored on this device. It does not create, modify, or remove server projects. + Choose the projects in your session inbox. Following is saved on this device only and does + not change server projects. {selection.preferencesLoading || selection.projectsLoading ? ( @@ -131,7 +126,7 @@ export function FollowedProjectsScreen({ navigation }: Props) { {path} - {selected ? "FOLLOWING" : "NOT FOLLOWED"} + {selected ? "✓ Following" : "Not followed"} {selected ? ( @@ -221,19 +216,22 @@ function projectLabel(project: { canonical: string; id: string; name?: string }) } const styles = StyleSheet.create({ - content: { gap: space.md, padding: space.lg, paddingBottom: space.xl }, - copy: { ...typography.body, color: palette.dim }, + copy: { ...typography.caption, color: palette.dim }, disabled: { opacity: 0.5 }, error: { ...typography.body, color: palette.danger }, - eyebrow: { ...typography.label, color: palette.dim }, followState: { ...typography.label, color: palette.dim }, followStateSelected: { color: palette.signal }, list: { gap: space.sm }, - orderActions: { flexDirection: "row", flexWrap: "wrap", gap: space.sm, padding: space.sm }, + orderActions: { + flexDirection: "row", + flexWrap: "wrap", + gap: space.xs, + paddingHorizontal: space.sm, + paddingBottom: space.xs, + }, orderButton: { - ...control, - borderColor: palette.border, - borderWidth: 1, + minHeight: 44, + paddingHorizontal: space.sm, justifyContent: "center", }, orderLabel: { ...typography.control, color: palette.ink }, @@ -244,12 +242,11 @@ const styles = StyleSheet.create({ borderRadius: radius.md, borderWidth: 1, }, - projectMain: { gap: space.xs, minHeight: 72, padding: space.md }, + projectMain: { gap: 4, minHeight: 72, padding: space.sm }, projectPath: { ...typography.caption, color: palette.dim }, - projectSelected: { borderColor: palette.signal }, + projectSelected: { borderColor: palette.border }, projectTitle: { ...typography.heading, color: palette.ink }, state: { alignItems: "center", gap: space.sm, paddingVertical: space.xl }, - title: { ...typography.title, color: palette.ink }, unavailableProject: { backgroundColor: palette.card, borderColor: palette.warm, diff --git a/apps/mobile/src/screens/form-request-card.tsx b/apps/mobile/src/screens/form-request-card.tsx index 59dc798..877e8e0 100644 --- a/apps/mobile/src/screens/form-request-card.tsx +++ b/apps/mobile/src/screens/form-request-card.tsx @@ -14,7 +14,8 @@ import { import { applicationName } from "../application-name"; import { ModalSheet } from "../components/modal-sheet"; -import { control, palette, radius, space, typeRamp, typography } from "../theme"; +import { requestCardStyles } from "../components/request-card-styles"; +import { palette, radius, space, typeRamp, typography } from "../theme"; import { createFormDraft, type FormDraft, @@ -541,34 +542,19 @@ const styles = StyleSheet.create({ borderTopColor: palette.border, }, choiceHeading: { flexDirection: "row", gap: space.sm, alignItems: "center" }, - actions: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, + actions: requestCardStyles.actions, booleanOptions: { flexDirection: "row", flexWrap: "wrap", gap: space.sm, }, - button: { - ...control, - alignItems: "center", - borderColor: palette.border, - borderWidth: 1, - justifyContent: "center", - }, + button: requestCardStyles.button, buttonPrimary: { backgroundColor: palette.signal }, buttonPrimaryLabel: { color: palette.background }, buttonDanger: { borderColor: palette.border }, buttonDangerLabel: { color: palette.danger }, - buttonLabel: { ...typography.control, color: palette.ink, textAlign: "center" }, - card: { - backgroundColor: palette.card, - borderColor: palette.border, - borderRadius: radius.lg, - borderWidth: 1, - borderLeftWidth: 3, - borderLeftColor: palette.warm, - gap: space.md, - padding: space.md, - }, + buttonLabel: requestCardStyles.buttonLabel, + card: requestCardStyles.card, choice: { borderColor: palette.border, borderRadius: radius.sm, diff --git a/apps/mobile/src/screens/permission-request-card.test.tsx b/apps/mobile/src/screens/permission-request-card.test.tsx index 1819273..091a922 100644 --- a/apps/mobile/src/screens/permission-request-card.test.tsx +++ b/apps/mobile/src/screens/permission-request-card.test.tsx @@ -1,6 +1,6 @@ import { expect, jest, test } from "@jest/globals"; import { fireEvent, render, screen } from "@testing-library/react-native"; -import { palette, typography } from "../theme"; +import { palette, radius, space, typography } from "../theme"; import { PermissionRequestCard } from "./permission-request-card"; const command = `python3 - <<'PY'\n${"print('review me')\n".repeat(100)}PY`; @@ -87,7 +87,12 @@ test("compact approval actions retain all three reply values", () => { ["Reject", "reject"], ] as const) { const button = screen.getByRole("button", { name }); - expect(button).toHaveStyle({ minHeight: 44 }); + expect(button).toHaveStyle({ + minHeight: 44, + borderRadius: radius.sm, + paddingHorizontal: space.sm, + }); + expect(button).not.toHaveStyle({ flexGrow: 1 }); fireEvent.press(button); expect(reply).toHaveBeenLastCalledWith(request.id, request.sessionID, value); } diff --git a/apps/mobile/src/screens/permission-request-card.tsx b/apps/mobile/src/screens/permission-request-card.tsx index 7e401f5..3a1112b 100644 --- a/apps/mobile/src/screens/permission-request-card.tsx +++ b/apps/mobile/src/screens/permission-request-card.tsx @@ -3,15 +3,8 @@ import type { PermissionReply, PermissionRequest } from "@opencode2-mobile/openc import { useState } from "react"; import { Pressable, StyleSheet, Text, useWindowDimensions, View } from "react-native"; import { ModalSheet } from "../components/modal-sheet"; -import { - control, - markdownPalette, - palette, - radius, - space, - typography, - usesLargeTextLayout, -} from "../theme"; +import { requestCardStyles } from "../components/request-card-styles"; +import { markdownPalette, palette, radius, space, typography, usesLargeTextLayout } from "../theme"; import { permissionActionExplanation } from "./permission-presentation"; import { sanitizeTranscriptText } from "./session-transcript-model"; @@ -161,14 +154,7 @@ const styles = StyleSheet.create({ headingRow: { flexDirection: "row", alignItems: "center", gap: space.xs }, headingTitle: { flex: 1 }, card: { - backgroundColor: palette.card, - borderColor: palette.border, - borderWidth: 1, - borderRadius: radius.md, - borderLeftWidth: 3, - borderLeftColor: palette.warm, - padding: 12, - gap: 8, + ...requestCardStyles.card, }, title: { ...typography.heading, color: palette.ink }, action: { ...typography.body, color: palette.ink }, @@ -186,28 +172,18 @@ const styles = StyleSheet.create({ caption: { ...typography.caption, color: palette.dim }, copy: { ...typography.body, color: palette.dim, marginVertical: space.sm }, actions: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, + ...requestCardStyles.actions, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: palette.border, }, - actionsLarge: { flexDirection: "column" }, + actionsLarge: { flexDirection: "column", alignItems: "flex-start" }, button: { - ...control, - minHeight: 44, - paddingHorizontal: space.sm, - paddingVertical: space.xs, - flexGrow: 1, - justifyContent: "center", - alignItems: "center", - borderColor: palette.border, - borderWidth: 1, + ...requestCardStyles.button, }, primary: { backgroundColor: palette.signal }, primaryLabel: { color: palette.background }, - buttonLabel: { ...typography.control, color: palette.ink, textAlign: "center" }, + buttonLabel: requestCardStyles.buttonLabel, reject: { color: palette.danger }, error: { ...typography.body, color: palette.danger }, disabled: { opacity: 0.5 },