authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-07-30 15:17:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 00:17:47+02:00
logcb293fe790fef3acba6220404f30c1665cde5fdb
treebeca63c2fe6c764c1aa15970e6f3cf107cccf273
parent1d694dcbd8d27c994b3bdc66c4bdb199faf2efc3
signature Signed by PGP key B5690EEEBB952194

fix(mobile): restore iOS Threads branding (#4862)


13 files changed, 174 insertions(+), 24 deletions(-)

apps/mobile/package.json+1-1
...@@ -11,7 +11,7 @@...@@ -11,7 +11,7 @@
11 "start:dev": "APP_VARIANT=development expo start",11 "start:dev": "APP_VARIANT=development expo start",
12 "start:preview": "APP_VARIANT=preview expo start",12 "start:preview": "APP_VARIANT=preview expo start",
13 "start:prod": "APP_VARIANT=production expo start",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 "screenshots": "node ../../scripts/mobile-showcase.ts",15 "screenshots": "node ../../scripts/mobile-showcase.ts",
16 "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",16 "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
17 "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android",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,6 +15,7 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re
15import { useResolveClassNames } from "uniwind";15import { useResolveClassNames } from "uniwind";
1616
17import { AppText as Text } from "./components/AppText";17import { AppText as Text } from "./components/AppText";
18import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
18import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";19import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
19import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";20import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
20import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";21import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
...@@ -392,7 +393,7 @@ export const RootStack = createNativeStackNavigator({...@@ -392,7 +393,7 @@ export const RootStack = createNativeStackNavigator({
392 ...GLASS_HEADER_OPTIONS,393 ...GLASS_HEADER_OPTIONS,
393 contentStyle: { backgroundColor: "transparent" },394 contentStyle: { backgroundColor: "transparent" },
394 headerBackVisible: false,395 headerBackVisible: false,
395 title: "Threads",396 ...getCompactBrandHeaderOptions(),
396 },397 },
397 }),398 }),
398 Thread: createNativeStackScreen({399 Thread: createNativeStackScreen({
apps/mobile/src/components/CompactBrandTitle.tsx created+121
...@@ -0,0 +1,121 @@
1import Constants from "expo-constants";
2import type {
3 NativeStackHeaderItem,
4 NativeStackNavigationOptions,
5} from "@react-navigation/native-stack";
6import { Platform, View } from "react-native";
7
8import { AppText as Text } from "./AppText";
9import { T3Wordmark } from "./T3Wordmark";
10import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics";
11import { resolveMobileStageLabel } from "../lib/mobileBranding";
12import { useThemeColor } from "../lib/useThemeColor";
13import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass";
14
15// Native leading items inherit different UIKit margins than title views.
16const IOS_NATIVE_LEADING_TITLE_OFFSET = -6;
17const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7;
18
19/**
20 * Compact brand lockup sized for native navigation bars.
21 */
22export 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
90export function renderCompactBrandTitle() {
91 return <CompactBrandTitle />;
92}
93
94export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] {
95 return [
96 {
97 element: <CompactBrandTitle nativeLeadingItem />,
98 hidesSharedBackground: true,
99 type: "custom",
100 },
101 ];
102}
103
104export 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,6 +3,7 @@ import * as Order from "effect/Order";
3import { useNavigation } from "@react-navigation/native";3import { useNavigation } from "@react-navigation/native";
4import { useEffect, useMemo, useState } from "react";4import { useEffect, useMemo, useState } from "react";
55
6import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle";
6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";7import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { useProjects, useThreadShells } from "../../state/entities";8import { useProjects, useThreadShells } from "../../state/entities";
8import { usePendingNewTasks } from "../../state/use-pending-new-tasks";9import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
...@@ -93,7 +94,9 @@ export function HomeRouteScreen() {...@@ -93,7 +94,9 @@ export function HomeRouteScreen() {
93 if (layout.usesSplitView) {94 if (layout.usesSplitView) {
94 return (95 return (
95 <>96 <>
96 <NativeStackScreenOptions options={{ title: "", headerTitle: "" }} />97 <NativeStackScreenOptions
98 options={{ title: "", headerTitle: "", unstable_headerLeftItems: () => [] }}
99 />
97 <WorkspaceSidebarToolbar100 <WorkspaceSidebarToolbar
98 afterSidebarButton={101 afterSidebarButton={
99 <NativeHeaderToolbar.Button102 <NativeHeaderToolbar.Button
...@@ -115,8 +118,8 @@ export function HomeRouteScreen() {...@@ -115,8 +118,8 @@ export function HomeRouteScreen() {
115 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}118 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
116 >119 >
117 <>120 <>
118 {/* Restore the compact title in case the split branch blanked it. */}121 {/* Restore the compact title after the split branch blanks the detail header. */}
119 <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />122 <NativeStackScreenOptions options={getCompactBrandHeaderOptions()} />
120 <HomeHeader123 <HomeHeader
121 environments={environments}124 environments={environments}
122 projects={projectFilterOptions}125 projects={projectFilterOptions}
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+2-2
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11import type { ReactNode } from "react";11import type { ReactNode } from "react";
12import { Platform, useColorScheme } from "react-native";12import { Platform, useColorScheme } from "react-native";
1313
14import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle";
14import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";15import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
15import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";16import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
1617
...@@ -35,10 +36,9 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = {...@@ -35,10 +36,9 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = {
35 headerShadowVisible: false,36 headerShadowVisible: false,
36 headerShown: true,37 headerShown: true,
37 headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined,38 headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined,
38 headerTitleStyle: { fontSize: 18, fontWeight: "800" },39 ...getCompactBrandHeaderOptions({ fontSize: 18, fontWeight: "800" }),
39 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,40 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
40 scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined,41 scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined,
41 title: "Threads",
42 unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined,42 unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined,
43};43};
4444
apps/mobile/src/features/threads/thread-list-items.tsx+2-1
...@@ -15,6 +15,7 @@ import { AppText as Text } from "../../components/AppText";...@@ -15,6 +15,7 @@ import { AppText as Text } from "../../components/AppText";
15import { ControlPillMenu } from "../../components/ControlPill";15import { ControlPillMenu } from "../../components/ControlPill";
16import { ProjectFavicon } from "../../components/ProjectFavicon";16import { ProjectFavicon } from "../../components/ProjectFavicon";
17import { cn } from "../../lib/cn";17import { cn } from "../../lib/cn";
18import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
18import { relativeTime } from "../../lib/time";19import { relativeTime } from "../../lib/time";
19import { useThemeColor } from "../../lib/useThemeColor";20import { useThemeColor } from "../../lib/useThemeColor";
20import type { PendingNewTask } from "../../state/use-pending-new-tasks";21import type { PendingNewTask } from "../../state/use-pending-new-tasks";
...@@ -33,7 +34,7 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match";...@@ -33,7 +34,7 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match";
33export type ThreadListVariant = "compact" | "sidebar";34export type ThreadListVariant = "compact" | "sidebar";
3435
35/** Left inset that aligns compact secondary rows with the title column. */36/** Left inset that aligns compact secondary rows with the title column. */
36export const THREAD_LIST_COMPACT_INSET = 20;37export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;
37const SIDEBAR_ROW_RADIUS = 12;38const SIDEBAR_ROW_RADIUS = 12;
3839
39function pullRequestTintColor(40function 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. */
2export const HOME_HORIZONTAL_INSET = 20;
3
4/** Compensates for the tighter native sidebar title margin on iPad. */
5export const IPAD_HOME_TITLE_OFFSET = 10;
apps/mobile/src/lib/mobileBranding.test.ts created+14
...@@ -0,0 +1,14 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveMobileStageLabel } from "./mobileBranding";
4
5describe("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 @@
1export type MobileStageLabel = "Alpha" | "Dev" | "Nightly";
2
3export 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,7 +370,8 @@ function NativeHeaderToolbarRoot(props: {
370 const navigation = useNativeStackNavigation();370 const navigation = useNativeStackNavigation();
371 const items = useMemo(() => collectToolbarItems(props.children), [props.children]);371 const items = useMemo(() => collectToolbarItems(props.children), [props.children]);
372372
373 useEffect(() => {373 // Swap toolbar owners before paint so split and compact headers cannot clear each other.
374 useLayoutEffect(() => {
374 if (!navigation) {375 if (!navigation) {
375 return;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,7 +43,7 @@ Captures wait for the real environment snapshot to hydrate and for the requested
43active. Both platforms record readiness in the simulator/emulator app container. A final settle43active. Both platforms record readiness in the simulator/emulator app container. A final settle
44delay allows native terminal and Git review data to finish rendering.44delay allows native terminal and Git review data to finish rendering.
4545
46A full capture regenerates the selected native project with Expo's clean development prebuild before46A full capture regenerates the selected native project with Expo's clean production prebuild before
47building it. Use --skip-build for repeated captures after the first build.47building it. Use --skip-build for repeated captures after the first build.
4848
49The harness uses fixed Metro port `8199`, which separates it from Expo's normal default port but is49The 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,22 +244,19 @@ it("selects a reachable LAN IPv4 address", () => {
244});244});
245245
246it("maps capture scenes to the real application routes", () => {246it("maps capture scenes to the real application routes", () => {
247 assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code-dev://");247 assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code://");
248 assert.equal(248 assert.equal(showcaseSceneUrl("environments", "environment-1"), "t3code://settings/environments");
249 showcaseSceneUrl("environments", "environment-1"),
250 "t3code-dev://settings/environments",
251 );
252 assert.equal(249 assert.equal(
253 showcaseSceneUrl("thread", "environment-1"),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 assert.equal(253 assert.equal(
257 showcaseSceneUrl("terminal", "environment-1"),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 assert.equal(257 assert.equal(
261 showcaseSceneUrl("review", "environment-1"),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});
265262
scripts/mobile-showcase.ts+6-6
...@@ -31,14 +31,14 @@ import {...@@ -31,14 +31,14 @@ import {
3131
32const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");32const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");
33const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile");33const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile");
34const ANDROID_PACKAGE = "com.t3tools.t3code.dev";34const ANDROID_PACKAGE = "com.t3tools.t3code";
35const APP_SCHEME = "t3code-dev";35const APP_SCHEME = "t3code";
36const IOS_READY_FILENAME = "T3ShowcaseReadyScene";36const IOS_READY_FILENAME = "T3ShowcaseReadyScene";
37const SERVER_HOST = "0.0.0.0";37const SERVER_HOST = "0.0.0.0";
38const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64";38const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64";
39const IOS_APP_PATH = NodePath.join(39const IOS_APP_PATH = NodePath.join(
40 MOBILE_ROOT,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);
43const ANDROID_APK_PATH = NodePath.join(43const ANDROID_APK_PATH = NodePath.join(
44 MOBILE_ROOT,44 MOBILE_ROOT,
...@@ -58,7 +58,7 @@ const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env);...@@ -58,7 +58,7 @@ const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env);
58const MOBILE_BUILD_ENV = {58const MOBILE_BUILD_ENV = {
59 ...NodeProcess.env,59 ...NodeProcess.env,
60 ANDROID_HOME: ANDROID_SDK_ROOT,60 ANDROID_HOME: ANDROID_SDK_ROOT,
61 APP_VARIANT: "development",61 APP_VARIANT: "production",
62 EXPO_NO_GIT_STATUS: "1",62 EXPO_NO_GIT_STATUS: "1",
63 JAVA_HOME:63 JAVA_HOME:
64 NodeProcess.env.JAVA_HOME ??64 NodeProcess.env.JAVA_HOME ??
...@@ -664,9 +664,9 @@ async function buildIos(): Promise<string> {...@@ -664,9 +664,9 @@ async function buildIos(): Promise<string> {
664 "xcodebuild",664 "xcodebuild",
665 [665 [
666 "-workspace",666 "-workspace",
667 NodePath.join(MOBILE_ROOT, "ios/T3CodeDev.xcworkspace"),667 NodePath.join(MOBILE_ROOT, "ios/T3Code.xcworkspace"),
668 "-scheme",668 "-scheme",
669 "T3CodeDev",669 "T3Code",
670 "-configuration",670 "-configuration",
671 "Debug",671 "Debug",
672 "-sdk",672 "-sdk",