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 @@
1111 "start:dev": "APP_VARIANT=development expo start",
1212 "start:preview": "APP_VARIANT=preview expo start",
1313 "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",
1515 "screenshots": "node ../../scripts/mobile-showcase.ts",
1616 "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
1717 "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
1515import { useResolveClassNames } from "uniwind";
1616
1717import { AppText as Text } from "./components/AppText";
18import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
1819import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
1920import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
2021import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
......@@ -392,7 +393,7 @@ export const RootStack = createNativeStackNavigator({
392393 ...GLASS_HEADER_OPTIONS,
393394 contentStyle: { backgroundColor: "transparent" },
394395 headerBackVisible: false,
395 title: "Threads",
396 ...getCompactBrandHeaderOptions(),
396397 },
397398 }),
398399 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";
33import { useNavigation } from "@react-navigation/native";
44import { useEffect, useMemo, useState } from "react";
55
6import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle";
67import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
78import { useProjects, useThreadShells } from "../../state/entities";
89import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
......@@ -93,7 +94,9 @@ export function HomeRouteScreen() {
9394 if (layout.usesSplitView) {
9495 return (
9596 <>
96 <NativeStackScreenOptions options={{ title: "", headerTitle: "" }} />
97 <NativeStackScreenOptions
98 options={{ title: "", headerTitle: "", unstable_headerLeftItems: () => [] }}
99 />
97100 <WorkspaceSidebarToolbar
98101 afterSidebarButton={
99102 <NativeHeaderToolbar.Button
......@@ -115,8 +118,8 @@ export function HomeRouteScreen() {
115118 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
116119 >
117120 <>
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()} />
120123 <HomeHeader
121124 environments={environments}
122125 projects={projectFilterOptions}
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+2-2
......@@ -11,6 +11,7 @@ import {
1111import type { ReactNode } from "react";
1212import { Platform, useColorScheme } from "react-native";
1313
14import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle";
1415import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
1516import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
1617
......@@ -35,10 +36,9 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = {
3536 headerShadowVisible: false,
3637 headerShown: true,
3738 headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined,
38 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
39 ...getCompactBrandHeaderOptions({ fontSize: 18, fontWeight: "800" }),
3940 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
4041 scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined,
41 title: "Threads",
4242 unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined,
4343};
4444
apps/mobile/src/features/threads/thread-list-items.tsx+2-1
......@@ -15,6 +15,7 @@ import { AppText as Text } from "../../components/AppText";
1515import { ControlPillMenu } from "../../components/ControlPill";
1616import { ProjectFavicon } from "../../components/ProjectFavicon";
1717import { cn } from "../../lib/cn";
18import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
1819import { relativeTime } from "../../lib/time";
1920import { useThemeColor } from "../../lib/useThemeColor";
2021import type { PendingNewTask } from "../../state/use-pending-new-tasks";
......@@ -33,7 +34,7 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match";
3334export type ThreadListVariant = "compact" | "sidebar";
3435
3536/** 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;
3738const SIDEBAR_ROW_RADIUS = 12;
3839
3940function 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: {
370370 const navigation = useNativeStackNavigation();
371371 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(() => {
374375 if (!navigation) {
375376 return;
376377 }
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
4343active. Both platforms record readiness in the simulator/emulator app container. A final settle
4444delay 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 before
46A full capture regenerates the selected native project with Expo's clean production prebuild before
4747building it. Use --skip-build for repeated captures after the first build.
4848
4949The 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", () => {
244244});
245245
246246it("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");
252249 assert.equal(
253250 showcaseSceneUrl("thread", "environment-1"),
254 "t3code-dev://threads/environment-1/remote-command-center",
251 "t3code://threads/environment-1/remote-command-center",
255252 );
256253 assert.equal(
257254 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",
259256 );
260257 assert.equal(
261258 showcaseSceneUrl("review", "environment-1"),
262 "t3code-dev://threads/environment-1/remote-command-center/review",
259 "t3code://threads/environment-1/remote-command-center/review",
263260 );
264261});
265262
scripts/mobile-showcase.ts+6-6
......@@ -31,14 +31,14 @@ import {
3131
3232const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");
3333const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile");
34const ANDROID_PACKAGE = "com.t3tools.t3code.dev";
35const APP_SCHEME = "t3code-dev";
34const ANDROID_PACKAGE = "com.t3tools.t3code";
35const APP_SCHEME = "t3code";
3636const IOS_READY_FILENAME = "T3ShowcaseReadyScene";
3737const SERVER_HOST = "0.0.0.0";
3838const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64";
3939const IOS_APP_PATH = NodePath.join(
4040 MOBILE_ROOT,
41 ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3CodeDev.app",
41 ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3Code.app",
4242);
4343const ANDROID_APK_PATH = NodePath.join(
4444 MOBILE_ROOT,
......@@ -58,7 +58,7 @@ const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env);
5858const MOBILE_BUILD_ENV = {
5959 ...NodeProcess.env,
6060 ANDROID_HOME: ANDROID_SDK_ROOT,
61 APP_VARIANT: "development",
61 APP_VARIANT: "production",
6262 EXPO_NO_GIT_STATUS: "1",
6363 JAVA_HOME:
6464 NodeProcess.env.JAVA_HOME ??
......@@ -664,9 +664,9 @@ async function buildIos(): Promise<string> {
664664 "xcodebuild",
665665 [
666666 "-workspace",
667 NodePath.join(MOBILE_ROOT, "ios/T3CodeDev.xcworkspace"),
667 NodePath.join(MOBILE_ROOT, "ios/T3Code.xcworkspace"),
668668 "-scheme",
669 "T3CodeDev",
669 "T3Code",
670670 "-configuration",
671671 "Debug",
672672 "-sdk",