diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c99351547be..f6816553184 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,7 +11,7 @@ "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", "start:prod": "APP_VARIANT=production expo start", - "showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear", + "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code --clear", "screenshots": "node ../../scripts/mobile-showcase.ts", "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 3aedb4e4e3e..57533d83c39 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,6 +15,7 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; +import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; @@ -383,7 +384,7 @@ export const RootStack = createNativeStackNavigator({ ...GLASS_HEADER_OPTIONS, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, - title: "Threads", + ...getCompactBrandHeaderOptions(), }, }), Thread: createNativeStackScreen({ diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx new file mode 100644 index 00000000000..f0710e85d36 --- /dev/null +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -0,0 +1,121 @@ +import Constants from "expo-constants"; +import type { + NativeStackHeaderItem, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { Platform, View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { T3Wordmark } from "./T3Wordmark"; +import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; +import { resolveMobileStageLabel } from "../lib/mobileBranding"; +import { useThemeColor } from "../lib/useThemeColor"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; + +// Native leading items inherit different UIKit margins than title views. +const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; +const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; + +/** + * Compact brand lockup sized for native navigation bars. + */ +export function CompactBrandTitle( + props: { + readonly nativeLeadingItem?: boolean; + } = {}, +) { + const iconColor = useThemeColor("--color-icon"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const subtleColor = useThemeColor("--color-subtle"); + const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); + const titleOffset = + Platform.OS !== "ios" + ? 0 + : props.nativeLeadingItem + ? Platform.isPad + ? IPAD_NATIVE_LEADING_TITLE_OFFSET + : IOS_NATIVE_LEADING_TITLE_OFFSET + : Platform.isPad + ? IPAD_HOME_TITLE_OFFSET + : 0; + + return ( + + + + Code + + + + {stageLabel} + + + + ); +} + +export function renderCompactBrandTitle() { + return ; +} + +export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] { + return [ + { + element: , + hidesSharedBackground: true, + type: "custom", + }, + ]; +} + +export function getCompactBrandHeaderOptions( + fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"], +): NativeStackNavigationOptions { + if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { + return { + headerTitle: "Threads", + headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, + title: "Threads", + unstable_headerLeftItems: renderCompactBrandHeaderItems, + }; + } + + return { + headerTitle: renderCompactBrandTitle, + headerTitleStyle: fallbackTitleStyle, + title: "Threads", + }; +} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 9fa179f4c76..77f7a7495c6 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -3,6 +3,7 @@ import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useState } from "react"; +import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; @@ -85,7 +86,9 @@ export function HomeRouteScreen() { if (layout.usesSplitView) { return ( <> - + [] }} + /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Restore the compact title in case the split branch blanked it. */} - + {/* Restore the compact title after the split branch blanks the detail header. */} + { + it.each([ + ["development", "Dev"], + ["preview", "Nightly"], + ["production", "Alpha"], + [undefined, "Alpha"], + ])("maps %s builds to %s", (appVariant, expected) => { + expect(resolveMobileStageLabel(appVariant)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/lib/mobileBranding.ts b/apps/mobile/src/lib/mobileBranding.ts new file mode 100644 index 00000000000..9fd6020831a --- /dev/null +++ b/apps/mobile/src/lib/mobileBranding.ts @@ -0,0 +1,7 @@ +export type MobileStageLabel = "Alpha" | "Dev" | "Nightly"; + +export function resolveMobileStageLabel(appVariant: unknown): MobileStageLabel { + if (appVariant === "development") return "Dev"; + if (appVariant === "preview") return "Nightly"; + return "Alpha"; +} diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 78c87119512..c3d9cec2457 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -364,7 +364,8 @@ function NativeHeaderToolbarRoot(props: { const navigation = useNativeStackNavigation(); const items = useMemo(() => collectToolbarItems(props.children), [props.children]); - useEffect(() => { + // Swap toolbar owners before paint so split and compact headers cannot clear each other. + useLayoutEffect(() => { if (!navigation) { return; } diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index 2c36ab9d008..cd7c8736d39 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -41,7 +41,7 @@ Captures wait for the real environment snapshot to hydrate and for the requested active. Both platforms record readiness in the simulator/emulator app container. A final settle delay allows native terminal and Git review data to finish rendering. -A full capture regenerates the selected native project with Expo's clean development prebuild before +A full capture regenerates the selected native project with Expo's clean production prebuild before building it. Use --skip-build for repeated captures after the first build. The harness uses its own Metro port (8199 by default), so an ordinary mobile server or another diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index 242c9ebdb67..a0deadbef65 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -244,22 +244,19 @@ it("selects a reachable LAN IPv4 address", () => { }); it("maps capture scenes to the real application routes", () => { - assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code-dev://"); - assert.equal( - showcaseSceneUrl("environments", "environment-1"), - "t3code-dev://settings/environments", - ); + assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code://"); + assert.equal(showcaseSceneUrl("environments", "environment-1"), "t3code://settings/environments"); assert.equal( showcaseSceneUrl("thread", "environment-1"), - "t3code-dev://threads/environment-1/remote-command-center", + "t3code://threads/environment-1/remote-command-center", ); assert.equal( showcaseSceneUrl("terminal", "environment-1"), - "t3code-dev://threads/environment-1/remote-command-center/terminal?terminalId=term-1", + "t3code://threads/environment-1/remote-command-center/terminal?terminalId=term-1", ); assert.equal( showcaseSceneUrl("review", "environment-1"), - "t3code-dev://threads/environment-1/remote-command-center/review", + "t3code://threads/environment-1/remote-command-center/review", ); }); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 85304aecd86..35d6ffd5202 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -31,14 +31,14 @@ import { const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile"); -const ANDROID_PACKAGE = "com.t3tools.t3code.dev"; -const APP_SCHEME = "t3code-dev"; +const ANDROID_PACKAGE = "com.t3tools.t3code"; +const APP_SCHEME = "t3code"; const IOS_READY_FILENAME = "T3ShowcaseReadyScene"; const SERVER_HOST = "0.0.0.0"; const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64"; const IOS_APP_PATH = NodePath.join( MOBILE_ROOT, - ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3CodeDev.app", + ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3Code.app", ); const ANDROID_APK_PATH = NodePath.join( MOBILE_ROOT, @@ -58,7 +58,7 @@ const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env); const MOBILE_BUILD_ENV = { ...NodeProcess.env, ANDROID_HOME: ANDROID_SDK_ROOT, - APP_VARIANT: "development", + APP_VARIANT: "production", EXPO_NO_GIT_STATUS: "1", JAVA_HOME: NodeProcess.env.JAVA_HOME ?? @@ -664,9 +664,9 @@ async function buildIos(): Promise { "xcodebuild", [ "-workspace", - NodePath.join(MOBILE_ROOT, "ios/T3CodeDev.xcworkspace"), + NodePath.join(MOBILE_ROOT, "ios/T3Code.xcworkspace"), "-scheme", - "T3CodeDev", + "T3Code", "-configuration", "Debug", "-sdk",