| author | |
| committer | |
| log | cb293fe790fef3acba6220404f30c1665cde5fdb |
| tree | beca63c2fe6c764c1aa15970e6f3cf107cccf273 |
| parent | 1d694dcbd8d27c994b3bdc66c4bdb199faf2efc3 |
| signature | Signed by PGP key B5690EEEBB952194 |
13 files changed, 174 insertions(+), 24 deletions(-)
apps/mobile/package.json+1-1| ... | ... | @@ -11,7 +11,7 @@ |
| 11 | 11 | "start:dev": "APP_VARIANT=development expo start", |
| 12 | 12 | "start:preview": "APP_VARIANT=preview expo start", |
| 13 | 13 | "start:prod": "APP_VARIANT=production expo start", |
| 14 | "showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear", | |
| 14 | "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code --clear", | |
| 15 | 15 | "screenshots": "node ../../scripts/mobile-showcase.ts", |
| 16 | 16 | "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", |
| 17 | 17 | "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", |
apps/mobile/src/Stack.tsx+2-1| ... | ... | @@ -15,6 +15,7 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re |
| 15 | 15 | import { useResolveClassNames } from "uniwind"; |
| 16 | 16 | |
| 17 | 17 | import { AppText as Text } from "./components/AppText"; |
| 18 | import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; | |
| 18 | 19 | import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; |
| 19 | 20 | import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; |
| 20 | 21 | import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; |
| ... | ... | @@ -392,7 +393,7 @@ export const RootStack = createNativeStackNavigator({ |
| 392 | 393 | ...GLASS_HEADER_OPTIONS, |
| 393 | 394 | contentStyle: { backgroundColor: "transparent" }, |
| 394 | 395 | headerBackVisible: false, |
| 395 | title: "Threads", | |
| 396 | ...getCompactBrandHeaderOptions(), | |
| 396 | 397 | }, |
| 397 | 398 | }), |
| 398 | 399 | Thread: createNativeStackScreen({ |
apps/mobile/src/components/CompactBrandTitle.tsx created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | import Constants from "expo-constants"; | |
| 2 | import type { | |
| 3 | NativeStackHeaderItem, | |
| 4 | NativeStackNavigationOptions, | |
| 5 | } from "@react-navigation/native-stack"; | |
| 6 | import { Platform, View } from "react-native"; | |
| 7 | ||
| 8 | import { AppText as Text } from "./AppText"; | |
| 9 | import { T3Wordmark } from "./T3Wordmark"; | |
| 10 | import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; | |
| 11 | import { resolveMobileStageLabel } from "../lib/mobileBranding"; | |
| 12 | import { useThemeColor } from "../lib/useThemeColor"; | |
| 13 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; | |
| 14 | ||
| 15 | // Native leading items inherit different UIKit margins than title views. | |
| 16 | const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; | |
| 17 | const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; | |
| 18 | ||
| 19 | /** | |
| 20 | * Compact brand lockup sized for native navigation bars. | |
| 21 | */ | |
| 22 | export function CompactBrandTitle( | |
| 23 | props: { | |
| 24 | readonly nativeLeadingItem?: boolean; | |
| 25 | } = {}, | |
| 26 | ) { | |
| 27 | const iconColor = useThemeColor("--color-icon"); | |
| 28 | const mutedColor = useThemeColor("--color-foreground-muted"); | |
| 29 | const subtleColor = useThemeColor("--color-subtle"); | |
| 30 | const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); | |
| 31 | const titleOffset = | |
| 32 | Platform.OS !== "ios" | |
| 33 | ? 0 | |
| 34 | : props.nativeLeadingItem | |
| 35 | ? Platform.isPad | |
| 36 | ? IPAD_NATIVE_LEADING_TITLE_OFFSET | |
| 37 | : IOS_NATIVE_LEADING_TITLE_OFFSET | |
| 38 | : Platform.isPad | |
| 39 | ? IPAD_HOME_TITLE_OFFSET | |
| 40 | : 0; | |
| 41 | ||
| 42 | return ( | |
| 43 | <View | |
| 44 | aria-level={1} | |
| 45 | accessibilityLabel="T3 Code, Threads" | |
| 46 | accessible | |
| 47 | role="heading" | |
| 48 | style={{ | |
| 49 | alignItems: "center", | |
| 50 | flexDirection: "row", | |
| 51 | gap: 6, | |
| 52 | marginLeft: titleOffset, | |
| 53 | }} | |
| 54 | > | |
| 55 | <T3Wordmark color={iconColor} height={15} /> | |
| 56 | <Text | |
| 57 | style={{ | |
| 58 | color: mutedColor, | |
| 59 | fontFamily: "DMSans-Medium", | |
| 60 | fontSize: 21, | |
| 61 | letterSpacing: -0.5, | |
| 62 | }} | |
| 63 | > | |
| 64 | Code | |
| 65 | </Text> | |
| 66 | <View | |
| 67 | style={{ | |
| 68 | backgroundColor: subtleColor, | |
| 69 | borderRadius: 999, | |
| 70 | paddingHorizontal: 6, | |
| 71 | paddingVertical: 2, | |
| 72 | }} | |
| 73 | > | |
| 74 | <Text | |
| 75 | style={{ | |
| 76 | color: mutedColor, | |
| 77 | fontFamily: "DMSans-Bold", | |
| 78 | fontSize: 9, | |
| 79 | letterSpacing: 0.9, | |
| 80 | textTransform: "uppercase", | |
| 81 | }} | |
| 82 | > | |
| 83 | {stageLabel} | |
| 84 | </Text> | |
| 85 | </View> | |
| 86 | </View> | |
| 87 | ); | |
| 88 | } | |
| 89 | ||
| 90 | export function renderCompactBrandTitle() { | |
| 91 | return <CompactBrandTitle />; | |
| 92 | } | |
| 93 | ||
| 94 | export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] { | |
| 95 | return [ | |
| 96 | { | |
| 97 | element: <CompactBrandTitle nativeLeadingItem />, | |
| 98 | hidesSharedBackground: true, | |
| 99 | type: "custom", | |
| 100 | }, | |
| 101 | ]; | |
| 102 | } | |
| 103 | ||
| 104 | export function getCompactBrandHeaderOptions( | |
| 105 | fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"], | |
| 106 | ): NativeStackNavigationOptions { | |
| 107 | if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { | |
| 108 | return { | |
| 109 | headerTitle: "Threads", | |
| 110 | headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, | |
| 111 | title: "Threads", | |
| 112 | unstable_headerLeftItems: renderCompactBrandHeaderItems, | |
| 113 | }; | |
| 114 | } | |
| 115 | ||
| 116 | return { | |
| 117 | headerTitle: renderCompactBrandTitle, | |
| 118 | headerTitleStyle: fallbackTitleStyle, | |
| 119 | title: "Threads", | |
| 120 | }; | |
| 121 | } |
apps/mobile/src/features/home/HomeRouteScreen.tsx+6-3| ... | ... | @@ -3,6 +3,7 @@ import * as Order from "effect/Order"; |
| 3 | 3 | import { useNavigation } from "@react-navigation/native"; |
| 4 | 4 | import { useEffect, useMemo, useState } from "react"; |
| 5 | 5 | |
| 6 | import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; | |
| 6 | 7 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 7 | 8 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 8 | 9 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| ... | ... | @@ -93,7 +94,9 @@ export function HomeRouteScreen() { |
| 93 | 94 | if (layout.usesSplitView) { |
| 94 | 95 | return ( |
| 95 | 96 | <> |
| 96 | <NativeStackScreenOptions options={{ title: "", headerTitle: "" }} /> | |
| 97 | <NativeStackScreenOptions | |
| 98 | options={{ title: "", headerTitle: "", unstable_headerLeftItems: () => [] }} | |
| 99 | /> | |
| 97 | 100 | <WorkspaceSidebarToolbar |
| 98 | 101 | afterSidebarButton={ |
| 99 | 102 | <NativeHeaderToolbar.Button |
| ... | ... | @@ -115,8 +118,8 @@ export function HomeRouteScreen() { |
| 115 | 118 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 116 | 119 | > |
| 117 | 120 | <> |
| 118 | {/* Restore the compact title in case the split branch blanked it. */} | |
| 119 | <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} /> | |
| 121 | {/* Restore the compact title after the split branch blanks the detail header. */} | |
| 122 | <NativeStackScreenOptions options={getCompactBrandHeaderOptions()} /> | |
| 120 | 123 | <HomeHeader |
| 121 | 124 | environments={environments} |
| 122 | 125 | projects={projectFilterOptions} |
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+2-2| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | import type { ReactNode } from "react"; |
| 12 | 12 | import { Platform, useColorScheme } from "react-native"; |
| 13 | 13 | |
| 14 | import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; | |
| 14 | 15 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 15 | 16 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 16 | 17 | |
| ... | ... | @@ -35,10 +36,9 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { |
| 35 | 36 | headerShadowVisible: false, |
| 36 | 37 | headerShown: true, |
| 37 | 38 | headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, |
| 38 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, | |
| 39 | ...getCompactBrandHeaderOptions({ fontSize: 18, fontWeight: "800" }), | |
| 39 | 40 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, |
| 40 | 41 | scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined, |
| 41 | title: "Threads", | |
| 42 | 42 | unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, |
| 43 | 43 | }; |
| 44 | 44 |
apps/mobile/src/features/threads/thread-list-items.tsx+2-1| ... | ... | @@ -15,6 +15,7 @@ import { AppText as Text } from "../../components/AppText"; |
| 15 | 15 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 16 | 16 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 17 | 17 | import { cn } from "../../lib/cn"; |
| 18 | import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; | |
| 18 | 19 | import { relativeTime } from "../../lib/time"; |
| 19 | 20 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 20 | 21 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| ... | ... | @@ -33,7 +34,7 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match"; |
| 33 | 34 | export type ThreadListVariant = "compact" | "sidebar"; |
| 34 | 35 | |
| 35 | 36 | /** Left inset that aligns compact secondary rows with the title column. */ |
| 36 | export const THREAD_LIST_COMPACT_INSET = 20; | |
| 37 | export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; | |
| 37 | 38 | const SIDEBAR_ROW_RADIUS = 12; |
| 38 | 39 | |
| 39 | 40 | function pullRequestTintColor( |
apps/mobile/src/lib/layoutMetrics.ts created+5| ... | ... | @@ -0,0 +1,5 @@ |
| 1 | /** Horizontal inset shared by the home header and compact thread list. */ | |
| 2 | export const HOME_HORIZONTAL_INSET = 20; | |
| 3 | ||
| 4 | /** Compensates for the tighter native sidebar title margin on iPad. */ | |
| 5 | export const IPAD_HOME_TITLE_OFFSET = 10; |
apps/mobile/src/lib/mobileBranding.test.ts created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveMobileStageLabel } from "./mobileBranding"; | |
| 4 | ||
| 5 | describe("resolveMobileStageLabel", () => { | |
| 6 | it.each([ | |
| 7 | ["development", "Dev"], | |
| 8 | ["preview", "Nightly"], | |
| 9 | ["production", "Alpha"], | |
| 10 | [undefined, "Alpha"], | |
| 11 | ])("maps %s builds to %s", (appVariant, expected) => { | |
| 12 | expect(resolveMobileStageLabel(appVariant)).toBe(expected); | |
| 13 | }); | |
| 14 | }); |
apps/mobile/src/lib/mobileBranding.ts created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | export type MobileStageLabel = "Alpha" | "Dev" | "Nightly"; | |
| 2 | ||
| 3 | export function resolveMobileStageLabel(appVariant: unknown): MobileStageLabel { | |
| 4 | if (appVariant === "development") return "Dev"; | |
| 5 | if (appVariant === "preview") return "Nightly"; | |
| 6 | return "Alpha"; | |
| 7 | } |
apps/mobile/src/native/StackHeader.tsx+2-1| ... | ... | @@ -370,7 +370,8 @@ function NativeHeaderToolbarRoot(props: { |
| 370 | 370 | const navigation = useNativeStackNavigation(); |
| 371 | 371 | const items = useMemo(() => collectToolbarItems(props.children), [props.children]); |
| 372 | 372 | |
| 373 | useEffect(() => { | |
| 373 | // Swap toolbar owners before paint so split and compact headers cannot clear each other. | |
| 374 | useLayoutEffect(() => { | |
| 374 | 375 | if (!navigation) { |
| 375 | 376 | return; |
| 376 | 377 | } |
docs/operations/mobile-app-store-screenshots.md+1-1| ... | ... | @@ -43,7 +43,7 @@ Captures wait for the real environment snapshot to hydrate and for the requested |
| 43 | 43 | active. Both platforms record readiness in the simulator/emulator app container. A final settle |
| 44 | 44 | delay allows native terminal and Git review data to finish rendering. |
| 45 | 45 | |
| 46 | A full capture regenerates the selected native project with Expo's clean development prebuild before | |
| 46 | A full capture regenerates the selected native project with Expo's clean production prebuild before | |
| 47 | 47 | building it. Use --skip-build for repeated captures after the first build. |
| 48 | 48 | |
| 49 | 49 | The harness uses fixed Metro port `8199`, which separates it from Expo's normal default port but is |
scripts/mobile-showcase.test.ts+5-8| ... | ... | @@ -244,22 +244,19 @@ it("selects a reachable LAN IPv4 address", () => { |
| 244 | 244 | }); |
| 245 | 245 | |
| 246 | 246 | it("maps capture scenes to the real application routes", () => { |
| 247 | assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code-dev://"); | |
| 248 | assert.equal( | |
| 249 | showcaseSceneUrl("environments", "environment-1"), | |
| 250 | "t3code-dev://settings/environments", | |
| 251 | ); | |
| 247 | assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code://"); | |
| 248 | assert.equal(showcaseSceneUrl("environments", "environment-1"), "t3code://settings/environments"); | |
| 252 | 249 | assert.equal( |
| 253 | 250 | showcaseSceneUrl("thread", "environment-1"), |
| 254 | "t3code-dev://threads/environment-1/remote-command-center", | |
| 251 | "t3code://threads/environment-1/remote-command-center", | |
| 255 | 252 | ); |
| 256 | 253 | assert.equal( |
| 257 | 254 | showcaseSceneUrl("terminal", "environment-1"), |
| 258 | "t3code-dev://threads/environment-1/remote-command-center/terminal?terminalId=term-1", | |
| 255 | "t3code://threads/environment-1/remote-command-center/terminal?terminalId=term-1", | |
| 259 | 256 | ); |
| 260 | 257 | assert.equal( |
| 261 | 258 | showcaseSceneUrl("review", "environment-1"), |
| 262 | "t3code-dev://threads/environment-1/remote-command-center/review", | |
| 259 | "t3code://threads/environment-1/remote-command-center/review", | |
| 263 | 260 | ); |
| 264 | 261 | }); |
| 265 | 262 |
scripts/mobile-showcase.ts+6-6| ... | ... | @@ -31,14 +31,14 @@ import { |
| 31 | 31 | |
| 32 | 32 | const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); |
| 33 | 33 | const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile"); |
| 34 | const ANDROID_PACKAGE = "com.t3tools.t3code.dev"; | |
| 35 | const APP_SCHEME = "t3code-dev"; | |
| 34 | const ANDROID_PACKAGE = "com.t3tools.t3code"; | |
| 35 | const APP_SCHEME = "t3code"; | |
| 36 | 36 | const IOS_READY_FILENAME = "T3ShowcaseReadyScene"; |
| 37 | 37 | const SERVER_HOST = "0.0.0.0"; |
| 38 | 38 | const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64"; |
| 39 | 39 | const IOS_APP_PATH = NodePath.join( |
| 40 | 40 | MOBILE_ROOT, |
| 41 | ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3CodeDev.app", | |
| 41 | ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3Code.app", | |
| 42 | 42 | ); |
| 43 | 43 | const ANDROID_APK_PATH = NodePath.join( |
| 44 | 44 | MOBILE_ROOT, |
| ... | ... | @@ -58,7 +58,7 @@ const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env); |
| 58 | 58 | const MOBILE_BUILD_ENV = { |
| 59 | 59 | ...NodeProcess.env, |
| 60 | 60 | ANDROID_HOME: ANDROID_SDK_ROOT, |
| 61 | APP_VARIANT: "development", | |
| 61 | APP_VARIANT: "production", | |
| 62 | 62 | EXPO_NO_GIT_STATUS: "1", |
| 63 | 63 | JAVA_HOME: |
| 64 | 64 | NodeProcess.env.JAVA_HOME ?? |
| ... | ... | @@ -664,9 +664,9 @@ async function buildIos(): Promise<string> { |
| 664 | 664 | "xcodebuild", |
| 665 | 665 | [ |
| 666 | 666 | "-workspace", |
| 667 | NodePath.join(MOBILE_ROOT, "ios/T3CodeDev.xcworkspace"), | |
| 667 | NodePath.join(MOBILE_ROOT, "ios/T3Code.xcworkspace"), | |
| 668 | 668 | "-scheme", |
| 669 | "T3CodeDev", | |
| 669 | "T3Code", | |
| 670 | 670 | "-configuration", |
| 671 | 671 | "Debug", |
| 672 | 672 | "-sdk", |