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",