authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-08-16 09:13:15+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-16 10:13:15+02:00
logd23b181da0cee78bcc327b6673218b776a3cb023
tree20fc4620d1c48487f9d6d29f5cd40a4b0e3c1f0a
parent3583cd27dccda1b35fe80d9411a95a7217cc8176
signature Signed by PGP key B5690EEEBB952194

feat(mobile): add built-in themes (#6619)


76 files changed, 3090 insertions(+), 838 deletions(-)

.github/workflows/mobile-showcase-screenshots.yml+23-6
...@@ -21,6 +21,19 @@ on:...@@ -21,6 +21,19 @@ on:
21 - both21 - both
22 - dark22 - dark
23 - light23 - light
24 theme:
25 description: Palette to capture (all multiplies the run by six)
26 required: true
27 default: t3-code
28 type: choice
29 options:
30 - t3-code
31 - t3-chat
32 - grove
33 - ocean
34 - ember
35 - iris
36 - all
2437
25permissions:38permissions:
26 contents: read39 contents: read
...@@ -33,7 +46,9 @@ jobs:...@@ -33,7 +46,9 @@ jobs:
33 name: iPhone 6.9, iPhone 6.5, and iPad 1346 name: iPhone 6.9, iPhone 6.5, and iPad 13
34 if: inputs.platform == 'all' || inputs.platform == 'ios'47 if: inputs.platform == 'all' || inputs.platform == 'ios'
35 runs-on: blacksmith-12vcpu-macos-2648 runs-on: blacksmith-12vcpu-macos-26
36 timeout-minutes: 6049 # Capturing every palette multiplies the device matrix by six, and only the
50 # one native build is shared between them.
51 timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }}
37 steps:52 steps:
38 - name: Checkout53 - name: Checkout
39 uses: actions/checkout@v654 uses: actions/checkout@v6
...@@ -62,10 +77,10 @@ jobs:...@@ -62,10 +77,10 @@ jobs:
62 "$vp_pnpm_bin/pnpm" --version77 "$vp_pnpm_bin/pnpm" --version
6378
64 - name: Capture iOS showcase79 - name: Capture iOS showcase
65 run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}"80 run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}"
6681
67 - name: Validate App Store Connect assets82 - name: Validate App Store Connect assets
68 run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only83 run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only
6984
70 - name: Upload iOS screenshots85 - name: Upload iOS screenshots
71 if: always()86 if: always()
...@@ -80,7 +95,9 @@ jobs:...@@ -80,7 +95,9 @@ jobs:
80 name: Android phone, 7-inch tablet, and 10-inch tablet95 name: Android phone, 7-inch tablet, and 10-inch tablet
81 if: inputs.platform == 'all' || inputs.platform == 'android'96 if: inputs.platform == 'all' || inputs.platform == 'android'
82 runs-on: blacksmith-16vcpu-ubuntu-240497 runs-on: blacksmith-16vcpu-ubuntu-2404
83 timeout-minutes: 6098 # Capturing every palette multiplies the device matrix by six, and only the
99 # one native build is shared between them.
100 timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }}
84 env:101 env:
85 T3_SHOWCASE_ANDROID_ABI: x86_64102 T3_SHOWCASE_ANDROID_ABI: x86_64
86 steps:103 steps:
...@@ -137,10 +154,10 @@ jobs:...@@ -137,10 +154,10 @@ jobs:
137 cores: 8154 cores: 8
138 ram-size: 4096M155 ram-size: 4096M
139 disable-animations: false156 disable-animations: false
140 script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}"157 script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}"
141158
142 - name: Validate Google Play assets159 - name: Validate Google Play assets
143 run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only160 run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only
144161
145 - name: Upload Android screenshots162 - name: Upload Android screenshots
146 if: always()163 if: always()
apps/mobile/global.css+15-5
...@@ -12,6 +12,7 @@...@@ -12,6 +12,7 @@
12 /* Page backgrounds */12 /* Page backgrounds */
13 --color-screen: #f2f2f7;13 --color-screen: #f2f2f7;
14 --color-sheet: rgba(242, 242, 247, 0.98);14 --color-sheet: rgba(242, 242, 247, 0.98);
15 --color-sheet-solid: #f2f2f7;
1516
16 /* Card / surface */17 /* Card / surface */
17 --color-card: #ffffff;18 --color-card: #ffffff;
...@@ -39,13 +40,16 @@...@@ -39,13 +40,16 @@
39 /* Primary action */40 /* Primary action */
40 --color-primary: #262626;41 --color-primary: #262626;
41 --color-primary-foreground: #ffffff;42 --color-primary-foreground: #ffffff;
42 --color-primary-shadow: rgba(0, 0, 0, 0.18);43 --color-primary-shadow: #000000;
4344
44 /* Secondary action */45 /* Secondary action */
45 --color-secondary: #ffffff;46 --color-secondary: #ffffff;
46 --color-secondary-foreground: #262626;47 --color-secondary-foreground: #262626;
47 --color-secondary-border: rgba(0, 0, 0, 0.08);48 --color-secondary-border: rgba(0, 0, 0, 0.08);
48 --color-switch-active: #34c759;49 --color-switch-active-track: #34c759;
50 --color-switch-active-thumb: #ffffff;
51 --color-switch-inactive-track: rgba(0, 0, 0, 0.08);
52 --color-switch-inactive-thumb: #8e8e93;
4953
50 /* Danger */54 /* Danger */
51 --color-danger: #fef2f2;55 --color-danger: #fef2f2;
...@@ -56,7 +60,7 @@...@@ -56,7 +60,7 @@
56 --color-input: #ffffff;60 --color-input: #ffffff;
57 --color-input-border: rgba(0, 0, 0, 0.1);61 --color-input-border: rgba(0, 0, 0, 0.1);
58 --color-sidebar-search: rgba(118, 118, 128, 0.12);62 --color-sidebar-search: rgba(118, 118, 128, 0.12);
59 --color-placeholder: #a3a3a3;63 --color-placeholder: #737373;
6064
61 /* Icons */65 /* Icons */
62 --color-icon: #262626;66 --color-icon: #262626;
...@@ -90,6 +94,7 @@...@@ -90,6 +94,7 @@
90 --color-user-bubble: #007aff;94 --color-user-bubble: #007aff;
91 --color-user-bubble-foreground: #ffffff;95 --color-user-bubble-foreground: #ffffff;
92 --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);96 --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
97 --color-user-bubble-skill-foreground: #f0abfc;
9398
94 /* Drawer / modal backdrop */99 /* Drawer / modal backdrop */
95 --color-backdrop: rgba(0, 0, 0, 0.22);100 --color-backdrop: rgba(0, 0, 0, 0.22);
...@@ -106,6 +111,7 @@...@@ -106,6 +111,7 @@
106 /* Page backgrounds */111 /* Page backgrounds */
107 --color-screen: #0a0a0a;112 --color-screen: #0a0a0a;
108 --color-sheet: rgba(14, 14, 14, 0.98);113 --color-sheet: rgba(14, 14, 14, 0.98);
114 --color-sheet-solid: #0e0e0e;
109115
110 /* Card / surface */116 /* Card / surface */
111 --color-card: #171717;117 --color-card: #171717;
...@@ -133,13 +139,16 @@...@@ -133,13 +139,16 @@
133 /* Primary action */139 /* Primary action */
134 --color-primary: #f5f5f5;140 --color-primary: #f5f5f5;
135 --color-primary-foreground: #0a0a0a;141 --color-primary-foreground: #0a0a0a;
136 --color-primary-shadow: rgba(0, 0, 0, 0.22);142 --color-primary-shadow: #000000;
137143
138 /* Secondary action */144 /* Secondary action */
139 --color-secondary: rgba(255, 255, 255, 0.04);145 --color-secondary: rgba(255, 255, 255, 0.04);
140 --color-secondary-foreground: #f5f5f5;146 --color-secondary-foreground: #f5f5f5;
141 --color-secondary-border: rgba(255, 255, 255, 0.06);147 --color-secondary-border: rgba(255, 255, 255, 0.06);
142 --color-switch-active: #30d158;148 --color-switch-active-track: #30d158;
149 --color-switch-active-thumb: #ffffff;
150 --color-switch-inactive-track: rgba(255, 255, 255, 0.06);
151 --color-switch-inactive-thumb: #8e8e93;
143152
144 /* Danger */153 /* Danger */
145 --color-danger: rgba(239, 68, 68, 0.14);154 --color-danger: rgba(239, 68, 68, 0.14);
...@@ -184,6 +193,7 @@...@@ -184,6 +193,7 @@
184 --color-user-bubble: #0a84ff;193 --color-user-bubble: #0a84ff;
185 --color-user-bubble-foreground: #ffffff;194 --color-user-bubble-foreground: #ffffff;
186 --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);195 --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
196 --color-user-bubble-skill-foreground: #f0abfc;
187197
188 /* Drawer / modal backdrop */198 /* Drawer / modal backdrop */
189 --color-backdrop: rgba(0, 0, 0, 0.48);199 --color-backdrop: rgba(0, 0, 0, 0.48);
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt+6
...@@ -22,6 +22,12 @@ class T3NativeControlsModule : Module() {...@@ -22,6 +22,12 @@ class T3NativeControlsModule : Module() {
22 storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene")22 storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene")
23 }23 }
2424
25 // The palette is fixed for the whole capture, so it only ever arrives as a
26 // launch extra — unlike the scene, which the runner rewrites in place.
27 Function("getShowcaseTheme") {
28 appContext.currentActivity?.intent?.getStringExtra("showcaseTheme")
29 }
30
25 Function("prepareShowcaseCapture") {31 Function("prepareShowcaseCapture") {
26 // Android app data is cleared by the host runner before launch.32 // Android app data is cleared by the host runner before launch.
27 }33 }
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+13
...@@ -33,6 +33,19 @@ public final class T3NativeControlsModule: Module {...@@ -33,6 +33,19 @@ public final class T3NativeControlsModule: Module {
33 return arguments[flagIndex + 1]33 return arguments[flagIndex + 1]
34 }34 }
3535
36 // The palette is fixed for the whole capture, so it only ever arrives as a
37 // launch argument — unlike the scene, which the runner rewrites in place.
38 Function("getShowcaseTheme") { () -> String? in
39 let arguments = ProcessInfo.processInfo.arguments
40 guard
41 let flagIndex = arguments.firstIndex(of: "--showcaseTheme"),
42 arguments.indices.contains(flagIndex + 1)
43 else {
44 return nil as String?
45 }
46 return arguments[flagIndex + 1]
47 }
48
36 Function("getShowcaseOrientation") { () -> String? in49 Function("getShowcaseOrientation") { () -> String? in
37 let arguments = ProcessInfo.processInfo.arguments50 let arguments = ProcessInfo.processInfo.arguments
38 guard51 guard
apps/mobile/src/App.tsx+42-35
...@@ -2,11 +2,11 @@ import { BlurTargetView } from "expo-blur";...@@ -2,11 +2,11 @@ import { BlurTargetView } from "expo-blur";
2import * as Linking from "expo-linking";2import * as Linking from "expo-linking";
3import * as SplashScreen from "expo-splash-screen";3import * as SplashScreen from "expo-splash-screen";
4import { useEffect } from "react";4import { useEffect } from "react";
5import { StatusBar, useColorScheme } from "react-native";5import { StatusBar } from "react-native";
6import { GestureHandlerRootView } from "react-native-gesture-handler";6import { GestureHandlerRootView } from "react-native-gesture-handler";
7import { KeyboardProvider } from "react-native-keyboard-controller";7import { KeyboardProvider } from "react-native-keyboard-controller";
8import { SafeAreaProvider } from "react-native-safe-area-context";8import { SafeAreaProvider } from "react-native-safe-area-context";
9import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native";9import { createStaticNavigation } from "@react-navigation/native";
1010
11import { RegistryContext } from "@effect/atom-react";11import { RegistryContext } from "@effect/atom-react";
12import { ConfirmDialogHost } from "./components/ConfirmDialogHost";12import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
...@@ -22,6 +22,7 @@ import { appAtomRegistry } from "./state/atom-registry";...@@ -22,6 +22,7 @@ import { appAtomRegistry } from "./state/atom-registry";
22import { OverlayPortalHost } from "./components/OverlayPortal";22import { OverlayPortalHost } from "./components/OverlayPortal";
23import { appBlurTargetRef } from "./lib/appBlurTarget";23import { appBlurTargetRef } from "./lib/appBlurTarget";
24import { useThemeColor } from "./lib/useThemeColor";24import { useThemeColor } from "./lib/useThemeColor";
25import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
2526
26import "../global.css";27import "../global.css";
2728
...@@ -58,45 +59,51 @@ function SplashScreenCoordinator() {...@@ -58,45 +59,51 @@ function SplashScreenCoordinator() {
58}59}
5960
60export default function App() {61export default function App() {
61 const colorScheme = useColorScheme();
62 const statusBarBg = useThemeColor("--color-status-bar");
63
64 return (62 return (
65 <RegistryContext.Provider value={appAtomRegistry}>63 <RegistryContext.Provider value={appAtomRegistry}>
66 <CloudAuthProvider>64 <CloudAuthProvider>
67 <AppearancePreferencesProvider>65 <AppearancePreferencesProvider>
68 <SplashScreenCoordinator />66 <AppContent />
69 <GestureHandlerRootView className="flex-1">
70 <KeyboardProvider statusBarTranslucent>
71 <SafeAreaProvider>
72 <StatusBar
73 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
74 backgroundColor={statusBarBg}
75 translucent
76 />
77 {/* The navigation theme drives the NATIVE header appearance: native-stack
78 forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
79 this, React Navigation defaults to its light theme and every native
80 header (glass buttons, title, materials) is forced light even when
81 the system is in dark mode. */}
82 {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}
83 <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}>
84 <IncomingShareProvider>
85 <Navigation
86 linking={appLinking}
87 theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
88 />
89 </IncomingShareProvider>
90 <ConfirmDialogHost />
91 </BlurTargetView>
92 {/* Anchored-menu overlays render here — in-window, so the
93 keyboard stays up while a dropdown is open. */}
94 <OverlayPortalHost />
95 </SafeAreaProvider>
96 </KeyboardProvider>
97 </GestureHandlerRootView>
98 </AppearancePreferencesProvider>67 </AppearancePreferencesProvider>
99 </CloudAuthProvider>68 </CloudAuthProvider>
100 </RegistryContext.Provider>69 </RegistryContext.Provider>
101 );70 );
102}71}
72
73function AppContent() {
74 const { themeAppearance } = useAppearancePreferences();
75 const statusBarBg = useThemeColor("--color-status-bar");
76 const navigationTheme = useMobileNavigationTheme(themeAppearance);
77
78 return (
79 <>
80 <SplashScreenCoordinator />
81 <GestureHandlerRootView className="flex-1">
82 <KeyboardProvider statusBarTranslucent>
83 <SafeAreaProvider>
84 <StatusBar
85 barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"}
86 backgroundColor={statusBarBg}
87 translucent
88 />
89 {/* The navigation theme drives the NATIVE header appearance: native-stack
90 forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
91 this, React Navigation defaults to its light theme and every native
92 header (glass buttons, title, materials) is forced light even when
93 the system is in dark mode. */}
94 {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}
95 <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}>
96 <IncomingShareProvider>
97 <Navigation linking={appLinking} theme={navigationTheme} />
98 </IncomingShareProvider>
99 <ConfirmDialogHost />
100 </BlurTargetView>
101 {/* Anchored-menu overlays render here — in-window, so the
102 keyboard stays up while a dropdown is open. */}
103 <OverlayPortalHost />
104 </SafeAreaProvider>
105 </KeyboardProvider>
106 </GestureHandlerRootView>
107 </>
108 );
109}
apps/mobile/src/Stack.tsx+8-19
...@@ -11,7 +11,7 @@ import {...@@ -11,7 +11,7 @@ import {
11 type NativeStackNavigationOptions,11 type NativeStackNavigationOptions,
12} from "@react-navigation/native-stack";12} from "@react-navigation/native-stack";
13import { useEffect, useRef } from "react";13import { useEffect, useRef } from "react";
14import { Platform, Pressable, ScrollView, StyleSheet } from "react-native";14import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
15import { useResolveClassNames } from "uniwind";15import { useResolveClassNames } from "uniwind";
1616
17import { AppText as Text } from "./components/AppText";17import { AppText as Text } from "./components/AppText";
...@@ -71,11 +71,7 @@ import {...@@ -71,11 +71,7 @@ import {
71} from "./features/sharing/incoming-share-presentation";71} from "./features/sharing/incoming-share-presentation";
72import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";72import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
73import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";73import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
74import {74import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
75 FORM_SHEET_PRESENTATION_OPTIONS,
76 NATIVE_SHEET_SURFACE_COLOR,
77 NATIVE_SHEET_SURFACE_CONTENT_STYLE,
78} from "./native/sheet-surface";
79import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";75import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
8076
81const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);77const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
...@@ -96,11 +92,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {...@@ -96,11 +92,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
96 headerLargeTitle: false,92 headerLargeTitle: false,
97 headerShadowVisible: false,93 headerShadowVisible: false,
98 headerShown: true,94 headerShown: true,
99 headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED95 headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined,
100 ? { backgroundColor: "transparent" }
101 : NATIVE_SHEET_SURFACE_COLOR !== undefined
102 ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
103 : undefined,
104 headerTitleStyle: { fontSize: 18, fontWeight: "800" },96 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
105 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,97 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
106 scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined,98 scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined,
...@@ -115,12 +107,6 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = {...@@ -115,12 +107,6 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = {
115 headerLargeTitle: false,107 headerLargeTitle: false,
116 headerShadowVisible: false,108 headerShadowVisible: false,
117 headerShown: true,109 headerShown: true,
118 headerStyle:
119 NATIVE_SHEET_SURFACE_COLOR !== undefined
120 ? // native-stack types this as `string`, but the native side accepts any
121 // ColorValue including DynamicColorIOS.
122 { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
123 : undefined,
124 headerTitleStyle: { fontSize: 18, fontWeight: "800" },110 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
125 headerTransparent: false,111 headerTransparent: false,
126 unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,112 unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,
...@@ -507,7 +493,6 @@ export const RootStack = createNativeStackNavigator({...@@ -507,7 +493,6 @@ export const RootStack = createNativeStackNavigator({
507 linking: `${THREAD_LINKING_PREFIX}/files`,493 linking: `${THREAD_LINKING_PREFIX}/files`,
508 options: {494 options: {
509 ...GLASS_HEADER_OPTIONS,495 ...GLASS_HEADER_OPTIONS,
510 contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE,
511 title: "Files",496 title: "Files",
512 },497 },
513 }),498 }),
...@@ -636,7 +621,11 @@ export const RootStack = createNativeStackNavigator({...@@ -636,7 +621,11 @@ export const RootStack = createNativeStackNavigator({
636 // The whole new-task flow (choose project → draft → add project) shares621 // The whole new-task flow (choose project → draft → add project) shares
637 // draft state via NewTaskFlowProvider. The expo-router era mounted it in622 // draft state via NewTaskFlowProvider. The expo-router era mounted it in
638 // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.623 // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.
639 layout: ({ children }) => <NewTaskFlowProvider>{children}</NewTaskFlowProvider>,624 layout: ({ children }) => (
625 <NewTaskFlowProvider>
626 <View className="flex-1 bg-sheet-solid">{children}</View>
627 </NewTaskFlowProvider>
628 ),
640 options: {629 options: {
641 gestureEnabled: true,630 gestureEnabled: true,
642 headerShown: false,631 headerShown: false,
apps/mobile/src/components/AndroidAnchoredMenu.tsx+4-2
...@@ -3,11 +3,12 @@ import { BlurView } from "expo-blur";...@@ -3,11 +3,12 @@ import { BlurView } from "expo-blur";
3import type { ReactNode } from "react";3import type { ReactNode } from "react";
4import { useCallback, useEffect, useRef, useState } from "react";4import { useCallback, useEffect, useRef, useState } from "react";
5import type { StyleProp, ViewStyle } from "react-native";5import type { StyleProp, ViewStyle } from "react-native";
6import { BackHandler, Pressable, ScrollView, useColorScheme, View } from "react-native";6import { BackHandler, Pressable, ScrollView, View } from "react-native";
7import { useKeyboardState } from "react-native-keyboard-controller";7import { useKeyboardState } from "react-native-keyboard-controller";
8import Animated, { FadeIn } from "react-native-reanimated";8import Animated, { FadeIn } from "react-native-reanimated";
99
10import { appBlurTargetRef } from "../lib/appBlurTarget";10import { appBlurTargetRef } from "../lib/appBlurTarget";
11import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
11import { useThemeColor } from "../lib/useThemeColor";12import { useThemeColor } from "../lib/useThemeColor";
12import { cn } from "../lib/cn";13import { cn } from "../lib/cn";
13import { type AppSymbolName, SymbolView } from "./AppSymbol";14import { type AppSymbolName, SymbolView } from "./AppSymbol";
...@@ -79,7 +80,8 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {...@@ -79,7 +80,8 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
79 const anchorRef = useRef<View>(null);80 const anchorRef = useRef<View>(null);
80 const overlayRef = useRef<View>(null);81 const overlayRef = useRef<View>(null);
8182
82 const isDarkMode = useColorScheme() === "dark";83 const { themeAppearance } = useAppearancePreferences();
84 const isDarkMode = themeAppearance === "dark";
83 const keyboardVisible = useKeyboardState((state) => state.isVisible);85 const keyboardVisible = useKeyboardState((state) => state.isVisible);
84 const keyboardHeight = useKeyboardState((state) => state.height);86 const keyboardHeight = useKeyboardState((state) => state.height);
85 const rippleColor = useThemeColor("--color-subtle");87 const rippleColor = useThemeColor("--color-subtle");
apps/mobile/src/components/AppSymbol.tsx+4
...@@ -49,6 +49,7 @@ import {...@@ -49,6 +49,7 @@ import {
49 IconLink,49 IconLink,
50 IconMessage,50 IconMessage,
51 IconMinus,51 IconMinus,
52 IconMoon,
52 IconNetwork,53 IconNetwork,
53 IconPalette,54 IconPalette,
54 IconPin,55 IconPin,
...@@ -62,6 +63,7 @@ import {...@@ -62,6 +63,7 @@ import {
62 IconServer,63 IconServer,
63 IconSettings,64 IconSettings,
64 IconSparkles,65 IconSparkles,
66 IconSun,
65 IconLayoutSidebarRight,67 IconLayoutSidebarRight,
66 IconTerminal2,68 IconTerminal2,
67 IconTextDecrease,69 IconTextDecrease,
...@@ -111,6 +113,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -111,6 +113,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
111 "doc.on.doc": IconCopy,113 "doc.on.doc": IconCopy,
112 "doc.text": IconFileText,114 "doc.text": IconFileText,
113 ellipsis: IconDots,115 ellipsis: IconDots,
116 moon: IconMoon,
114 "ellipsis.circle": IconDotsCircleHorizontal,117 "ellipsis.circle": IconDotsCircleHorizontal,
115 "exclamationmark.triangle": IconAlertTriangle,118 "exclamationmark.triangle": IconAlertTriangle,
116 eye: IconEye,119 eye: IconEye,
...@@ -139,6 +142,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -139,6 +142,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
139 "slider.horizontal.3": IconAdjustmentsHorizontal,142 "slider.horizontal.3": IconAdjustmentsHorizontal,
140 "square.and.pencil": IconEdit,143 "square.and.pencil": IconEdit,
141 "square.split.2x1": IconLayoutColumns,144 "square.split.2x1": IconLayoutColumns,
145 "sun.max": IconSun,
142 "stop.fill": IconPlayerStopFilled,146 "stop.fill": IconPlayerStopFilled,
143 terminal: IconTerminal2,147 terminal: IconTerminal2,
144 "text.bubble": IconMessage,148 "text.bubble": IconMessage,
apps/mobile/src/components/ComposerToolbar.tsx+8-6
...@@ -4,7 +4,6 @@ import {...@@ -4,7 +4,6 @@ import {
4 Pressable,4 Pressable,
5 ScrollView,5 ScrollView,
6 View,6 View,
7 useColorScheme,
8 type LayoutChangeEvent,7 type LayoutChangeEvent,
9 type NativeScrollEvent,8 type NativeScrollEvent,
10 type NativeSyntheticEvent,9 type NativeSyntheticEvent,
...@@ -13,6 +12,8 @@ import {...@@ -13,6 +12,8 @@ import {
13} from "react-native";12} from "react-native";
1413
15import { useThemeColor } from "../lib/useThemeColor";14import { useThemeColor } from "../lib/useThemeColor";
15import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
16import { themeColorWithAlpha } from "../lib/mobileTheme";
16import { cn } from "../lib/cn";17import { cn } from "../lib/cn";
17import { AppText as Text } from "./AppText";18import { AppText as Text } from "./AppText";
18import { SymbolView } from "./AppSymbol";19import { SymbolView } from "./AppSymbol";
...@@ -213,21 +214,22 @@ export function ComposerToolbarButton(props: {...@@ -213,21 +214,22 @@ export function ComposerToolbarButton(props: {
213 readonly className?: string;214 readonly className?: string;
214 readonly style?: StyleProp<ViewStyle>;215 readonly style?: StyleProp<ViewStyle>;
215}) {216}) {
216 const isDarkMode = useColorScheme() === "dark";217 const { themeAppearance } = useAppearancePreferences();
218 const isDarkMode = themeAppearance === "dark";
217 const iconColor = useThemeColor("--color-icon");219 const iconColor = useThemeColor("--color-icon");
218 const iconSubtle = useThemeColor("--color-icon-subtle");220 const iconSubtle = useThemeColor("--color-icon-subtle");
219 const primaryFg = useThemeColor("--color-primary-foreground");221 const primaryFg = useThemeColor("--color-primary-foreground");
220 const dangerFg = useThemeColor("--color-danger-foreground");222 const dangerFg = useThemeColor("--color-danger-foreground");
221 const variant = props.variant ?? "default";223 const variant = props.variant ?? "default";
222 const isCircle = !props.label && props.showChevron === false;224 const isCircle = !props.label && props.showChevron === false;
223 const defaultBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";225 const defaultBorderColor = useThemeColor("--color-border-subtle");
224 const activeBorderColor = isDarkMode ? "rgba(255,255,255,0.13)" : "rgba(0,0,0,0.1)";226 const activeBorderColor = useThemeColor("--color-border");
225 const filledBorderColor =227 const filledBorderColor =
226 variant === "danger"228 variant === "danger"
227 ? "rgba(255,255,255,0.14)"229 ? themeColorWithAlpha(String(dangerFg), 0.14)
228 : props.disabled230 : props.disabled
229 ? defaultBorderColor231 ? defaultBorderColor
230 : "rgba(255,255,255,0.18)";232 : themeColorWithAlpha(String(primaryFg), 0.18);
231 const iconTintColor =233 const iconTintColor =
232 variant === "primary"234 variant === "primary"
233 ? props.disabled235 ? props.disabled
apps/mobile/src/components/ControlPill.tsx+4-2
...@@ -8,8 +8,9 @@ import {...@@ -8,8 +8,9 @@ import {
8 type ReactNode,8 type ReactNode,
9 useRef,9 useRef,
10} from "react";10} from "react";
11import { Platform, Pressable, useColorScheme, View } from "react-native";11import { Platform, Pressable, View } from "react-native";
12import { useThemeColor } from "../lib/useThemeColor";12import { useThemeColor } from "../lib/useThemeColor";
13import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1314
14import { cn } from "../lib/cn";15import { cn } from "../lib/cn";
15import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";16import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
...@@ -117,7 +118,8 @@ export function ControlPillMenu(...@@ -117,7 +118,8 @@ export function ControlPillMenu(
117 readonly className?: string;118 readonly className?: string;
118 },119 },
119) {120) {
120 const isDarkMode = useColorScheme() === "dark";121 const { themeAppearance } = useAppearancePreferences();
122 const isDarkMode = themeAppearance === "dark";
121123
122 if (Platform.OS === "android") {124 if (Platform.OS === "android") {
123 // Long-press menus keep their child interactive: the child element gets125 // Long-press menus keep their child interactive: the child element gets
apps/mobile/src/components/GlassSurface.tsx+3-2
...@@ -2,7 +2,6 @@ import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";...@@ -2,7 +2,6 @@ import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
2import type { ReactNode } from "react";2import type { ReactNode } from "react";
3import {3import {
4 Platform,4 Platform,
5 useColorScheme,
6 View,5 View,
7 type ColorValue,6 type ColorValue,
8 type StyleProp,7 type StyleProp,
...@@ -10,6 +9,7 @@ import {...@@ -10,6 +9,7 @@ import {
10 type ViewStyle,9 type ViewStyle,
11} from "react-native";10} from "react-native";
12import { useThemeColor } from "../lib/useThemeColor";11import { useThemeColor } from "../lib/useThemeColor";
12import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1313
14interface GlassSurfaceProps extends Omit<ViewProps, "className"> {14interface GlassSurfaceProps extends Omit<ViewProps, "className"> {
15 readonly children: ReactNode;15 readonly children: ReactNode;
...@@ -29,7 +29,8 @@ export function GlassSurface({...@@ -29,7 +29,8 @@ export function GlassSurface({
29 style,29 style,
30 ...props30 ...props
31}: GlassSurfaceProps) {31}: GlassSurfaceProps) {
32 const isDarkMode = useColorScheme() === "dark";32 const { themeAppearance } = useAppearancePreferences();
33 const isDarkMode = themeAppearance === "dark";
33 const borderColor = useThemeColor("--color-border");34 const borderColor = useThemeColor("--color-border");
34 const glassSurface = useThemeColor("--color-glass-surface");35 const glassSurface = useThemeColor("--color-glass-surface");
35 const glassTint = useThemeColor("--color-glass-tint");36 const glassTint = useThemeColor("--color-glass-tint");
apps/mobile/src/components/LoadingScreen.tsx+3-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native";1import { ActivityIndicator, StatusBar, View } from "react-native";
2import { useSafeAreaInsets } from "react-native-safe-area-context";2import { useSafeAreaInsets } from "react-native-safe-area-context";
3import { useThemeColor } from "../lib/useThemeColor";3import { useThemeColor } from "../lib/useThemeColor";
4import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
45
5import { AppText as Text } from "./AppText";6import { AppText as Text } from "./AppText";
6import { BrandMark } from "./BrandMark";7import { BrandMark } from "./BrandMark";
...@@ -9,7 +10,7 @@ export function LoadingScreen(props: {...@@ -9,7 +10,7 @@ export function LoadingScreen(props: {
9 readonly message: string;10 readonly message: string;
10 readonly messagePlacement?: "above-spinner" | "below-spinner";11 readonly messagePlacement?: "above-spinner" | "below-spinner";
11}) {12}) {
12 const colorScheme = useColorScheme();13 const { themeAppearance: colorScheme } = useAppearancePreferences();
13 const screenBg = useThemeColor("--color-screen");14 const screenBg = useThemeColor("--color-screen");
14 const insets = useSafeAreaInsets();15 const insets = useSafeAreaInsets();
15 const messagePlacement = props.messagePlacement ?? "below-spinner";16 const messagePlacement = props.messagePlacement ?? "below-spinner";
apps/mobile/src/components/PierreEntryIcon.tsx+3-1
...@@ -3,6 +3,7 @@ import { Image, type ImageStyle, type StyleProp } from "react-native";...@@ -3,6 +3,7 @@ import { Image, type ImageStyle, type StyleProp } from "react-native";
33
4import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";4import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
5import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";5import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
6import { useThemeColor } from "../lib/useThemeColor";
67
7export function PierreEntryIcon(props: {8export function PierreEntryIcon(props: {
8 readonly path: string;9 readonly path: string;
...@@ -11,8 +12,9 @@ export function PierreEntryIcon(props: {...@@ -11,8 +12,9 @@ export function PierreEntryIcon(props: {
11 readonly style?: StyleProp<ImageStyle>;12 readonly style?: StyleProp<ImageStyle>;
12}) {13}) {
13 const size = props.size ?? 16;14 const size = props.size ?? 16;
15 const folderColor = useThemeColor("--color-icon-subtle");
14 if (props.kind === "directory") {16 if (props.kind === "directory") {
15 return <SymbolView name="folder" size={size} tintColor="#a1a1aa" type="monochrome" />;17 return <SymbolView name="folder" size={size} tintColor={folderColor} type="monochrome" />;
16 }18 }
1719
18 return (20 return (
apps/mobile/src/components/ProviderIcon.tsx+3-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useColorScheme } from "react-native";
2import { Path, Svg } from "react-native-svg";1import { Path, Svg } from "react-native-svg";
2import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
33
4type ProviderIconProps = {4type ProviderIconProps = {
5 readonly provider: string | null | undefined;5 readonly provider: string | null | undefined;
...@@ -7,7 +7,8 @@ type ProviderIconProps = {...@@ -7,7 +7,8 @@ type ProviderIconProps = {
7};7};
88
9export function ProviderIcon(props: ProviderIconProps) {9export function ProviderIcon(props: ProviderIconProps) {
10 const isDarkMode = useColorScheme() === "dark";10 const { themeAppearance } = useAppearancePreferences();
11 const isDarkMode = themeAppearance === "dark";
11 const size = props.size ?? 16;12 const size = props.size ?? 16;
12 const mono = isDarkMode ? "#e5e5e5" : "#171717";13 const mono = isDarkMode ? "#e5e5e5" : "#171717";
1314
apps/mobile/src/components/ThemedSwitch.tsx created+21
...@@ -0,0 +1,21 @@
1import { Platform, Switch, type SwitchProps } from "react-native";
2
3import { useThemeColor } from "../lib/useThemeColor";
4
5export function ThemedSwitch(props: SwitchProps) {
6 const activeTrack = String(useThemeColor("--color-switch-active-track"));
7 const inactiveTrack = String(useThemeColor("--color-switch-inactive-track"));
8 const activeThumb = String(useThemeColor("--color-switch-active-thumb"));
9 const inactiveThumb = String(useThemeColor("--color-switch-inactive-thumb"));
10
11 return (
12 <Switch
13 {...props}
14 ios_backgroundColor={inactiveTrack}
15 thumbColor={
16 Platform.OS === "android" ? (props.value ? activeThumb : inactiveThumb) : undefined
17 }
18 trackColor={{ false: inactiveTrack, true: activeTrack }}
19 />
20 );
21}
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+2-6
...@@ -9,13 +9,13 @@ import { useCallback, useState } from "react";...@@ -9,13 +9,13 @@ import { useCallback, useState } from "react";
9import {9import {
10 ActivityIndicator,10 ActivityIndicator,
11 Pressable,11 Pressable,
12 Switch,
13 type NativeSyntheticEvent,12 type NativeSyntheticEvent,
14 type TextLayoutEventData,13 type TextLayoutEventData,
15 View,14 View,
16} from "react-native";15} from "react-native";
1716
18import { AppText as Text } from "../../components/AppText";17import { AppText as Text } from "../../components/AppText";
18import { ThemedSwitch } from "../../components/ThemedSwitch";
19import { cn } from "../../lib/cn";19import { cn } from "../../lib/cn";
20import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";20import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
21import { useThemeColor } from "../../lib/useThemeColor";21import { useThemeColor } from "../../lib/useThemeColor";
...@@ -275,8 +275,6 @@ function CloudEnvironmentRowShell(props: {...@@ -275,8 +275,6 @@ function CloudEnvironmentRowShell(props: {
275 readonly statusText?: string;275 readonly statusText?: string;
276 readonly value: boolean;276 readonly value: boolean;
277}) {277}) {
278 const activeTrack = String(useThemeColor("--color-switch-active"));
279 const track = String(useThemeColor("--color-secondary-border"));
280 const chevron = useThemeColor("--color-chevron");278 const chevron = useThemeColor("--color-chevron");
281 const isRetrying =279 const isRetrying =
282 props.connectionState === "connecting" || props.connectionState === "reconnecting";280 props.connectionState === "connecting" || props.connectionState === "reconnecting";
...@@ -389,11 +387,9 @@ function CloudEnvironmentRowShell(props: {...@@ -389,11 +387,9 @@ function CloudEnvironmentRowShell(props: {
389 ) : null}387 ) : null}
390 </StatusContainer>388 </StatusContainer>
391 </View>389 </View>
392 <Switch390 <ThemedSwitch
393 disabled={props.disabled}391 disabled={props.disabled}
394 ios_backgroundColor={track}
395 onValueChange={props.onValueChange}392 onValueChange={props.onValueChange}
396 trackColor={{ false: track, true: activeTrack }}
397 value={props.value}393 value={props.value}
398 />394 />
399 </View>395 </View>
apps/mobile/src/features/files/SourceFileSurface.tsx+10-13
...@@ -2,14 +2,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -2,14 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";2import { AsyncResult } from "effect/unstable/reactivity";
3import type { ComponentType } from "react";3import type { ComponentType } from "react";
4import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";4import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
5import {5import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native";
6 FlatList,
7 ScrollView,
8 Text as NativeText,
9 useColorScheme,
10 useWindowDimensions,
11 View,
12} from "react-native";
136
14import { AppText as Text } from "../../components/AppText";7import { AppText as Text } from "../../components/AppText";
15import { LoadingStrip } from "../../components/LoadingStrip";8import { LoadingStrip } from "../../components/LoadingStrip";
...@@ -23,6 +16,7 @@ import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";...@@ -23,6 +16,7 @@ import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
23import { cn } from "../../lib/cn";16import { cn } from "../../lib/cn";
24import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";17import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
25import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";18import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
19import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
26import {20import {
27 buildNativeSourceTokens,21 buildNativeSourceTokens,
28 NATIVE_SOURCE_CONTENT_WIDTH,22 NATIVE_SOURCE_CONTENT_WIDTH,
...@@ -115,8 +109,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {...@@ -115,8 +109,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
115});109});
116110
117function useSourceFileModel(props: SourceFileSurfaceProps) {111function useSourceFileModel(props: SourceFileSurfaceProps) {
118 const colorScheme = useColorScheme();112 const { themeAppearance: theme } = useAppearancePreferences();
119 const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";
120 const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]);113 const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]);
121 const { contents: normalizedContents, lines, rowsJson } = document;114 const { contents: normalizedContents, lines, rowsJson } = document;
122 const targetIndex =115 const targetIndex =
...@@ -159,8 +152,9 @@ function NativeSourceFileSurface(...@@ -159,8 +152,9 @@ function NativeSourceFileSurface(
159) {152) {
160 const { NativeView, onRefresh } = props;153 const { NativeView, onRefresh } = props;
161 const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();154 const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
155 const { themeAppearance, themeId } = useAppearancePreferences();
162 const { width: viewportWidth } = useWindowDimensions();156 const { width: viewportWidth } = useWindowDimensions();
163 const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props);157 const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props);
164 const [isPullRefreshing, setIsPullRefreshing] = useState(false);158 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
165 const handlePullToRefresh = useCallback(async () => {159 const handlePullToRefresh = useCallback(async () => {
166 if (!onRefresh) {160 if (!onRefresh) {
...@@ -178,7 +172,10 @@ function NativeSourceFileSurface(...@@ -178,7 +172,10 @@ function NativeSourceFileSurface(
178 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),172 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
179 [targetIndex],173 [targetIndex],
180 );174 );
181 const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]);175 const themeJson = useMemo(
176 () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId)),
177 [themeAppearance, themeId],
178 );
182 const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]);179 const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]);
183 const contentWidth = codeWordBreak180 const contentWidth = codeWordBreak
184 ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24)181 ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24)
...@@ -191,7 +188,7 @@ function NativeSourceFileSurface(...@@ -191,7 +188,7 @@ function NativeSourceFileSurface(
191 collapsable={false}188 collapsable={false}
192 testID="source-native-code-view"189 testID="source-native-code-view"
193 style={{ flex: 1 }}190 style={{ flex: 1 }}
194 appearanceScheme={theme}191 appearanceScheme={themeAppearance}
195 contentResetKey={props.path}192 contentResetKey={props.path}
196 contentWidth={contentWidth}193 contentWidth={contentWidth}
197 initialRowIndex={targetIndex ?? -1}194 initialRowIndex={targetIndex ?? -1}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+8-5
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useCallback, useEffect, useRef, useState } from "react";3import { useCallback, useEffect, useRef, useState } from "react";
4import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";4import { ActivityIndicator, Platform, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
6import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";6import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
7import {7import {
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35} from "../layout/native-mail-search-toolbar";35} from "../layout/native-mail-search-toolbar";
36import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";36import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
37import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";37import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
38import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
38import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";39import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
39import { FileMarkdownPreview } from "./FileMarkdownPreview";40import { FileMarkdownPreview } from "./FileMarkdownPreview";
40import { FileTreeBrowser } from "./FileTreeBrowser";41import { FileTreeBrowser } from "./FileTreeBrowser";
...@@ -242,10 +243,10 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -242,10 +243,10 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
242 const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =243 const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =
243 useAdaptiveWorkspaceLayout();244 useAdaptiveWorkspaceLayout();
244 const [searchQuery, setSearchQuery] = useState("");245 const [searchQuery, setSearchQuery] = useState("");
245 const colorScheme = useColorScheme();
246 const isAndroid = Platform.OS === "android";246 const isAndroid = Platform.OS === "android";
247 const highlightTheme = colorScheme === "dark" ? "dark" : "light";247 const { themeAppearance: highlightTheme } = useAppearancePreferences();
248 const iconColor = String(useThemeColor("--color-icon-muted"));248 const iconColor = String(useThemeColor("--color-icon-muted"));
249 const sheetSurfaceColor = String(useThemeColor("--color-sheet-solid"));
249 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(250 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
250 props.route.params,251 props.route.params,
251 );252 );
...@@ -362,10 +363,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -362,10 +363,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
362363
363 return (364 return (
364 <>365 <>
365 {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx.366 {/* Static header config (glass preset and title) lives in Stack.tsx. The
366 Only genuinely dynamic options are set here. */}367 live sheet color stays dynamic here so the FlatList can remain the
368 direct scene child for native scroll-edge sampling. */}
367 <NativeStackScreenOptions369 <NativeStackScreenOptions
368 options={{370 options={{
371 contentStyle: { backgroundColor: sheetSurfaceColor },
369 headerShown: !isAndroid,372 headerShown: !isAndroid,
370 unstable_headerSubtitle:373 unstable_headerSubtitle:
371 Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,374 Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+3-3
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";1import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";
2import { SymbolView } from "../../components/AppSymbol";2import { SymbolView } from "../../components/AppSymbol";
3import { useCallback, useMemo, useState, type ComponentProps } from "react";3import { useCallback, useMemo, useState, type ComponentProps } from "react";
4import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native";4import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native";
5import {5import {
6 Screen,6 Screen,
7 ScreenStack,7 ScreenStack,
...@@ -15,6 +15,7 @@ import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";...@@ -15,6 +15,7 @@ import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
15import { useThemeColor } from "../../lib/useThemeColor";15import { useThemeColor } from "../../lib/useThemeColor";
16import { projectEnvironment } from "../../state/projects";16import { projectEnvironment } from "../../state/projects";
17import { useEnvironmentQuery } from "../../state/query";17import { useEnvironmentQuery } from "../../state/query";
18import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
18import { FileTreeBrowser } from "./FileTreeBrowser";19import { FileTreeBrowser } from "./FileTreeBrowser";
19import { preloadWorkspaceFileContents } from "./preload-workspace-file";20import { preloadWorkspaceFileContents } from "./preload-workspace-file";
2021
...@@ -27,8 +28,7 @@ export function ThreadFileNavigatorPane(props: {...@@ -27,8 +28,7 @@ export function ThreadFileNavigatorPane(props: {
27 readonly onSelectFile: (path: string) => void;28 readonly onSelectFile: (path: string) => void;
28}) {29}) {
29 const [searchQuery, setSearchQuery] = useState("");30 const [searchQuery, setSearchQuery] = useState("");
30 const colorScheme = useColorScheme();31 const { themeAppearance: highlightTheme } = useAppearancePreferences();
31 const highlightTheme = colorScheme === "dark" ? "dark" : "light";
32 const iconColor = String(useThemeColor("--color-icon-muted"));32 const iconColor = String(useThemeColor("--color-icon-muted"));
33 const foregroundColor = String(useThemeColor("--color-foreground"));33 const foregroundColor = String(useThemeColor("--color-foreground"));
34 const sheetColor = String(useThemeColor("--color-sheet"));34 const sheetColor = String(useThemeColor("--color-sheet"));
apps/mobile/src/features/layout/workspace-pane-divider.tsx+11-12
...@@ -1,14 +1,8 @@...@@ -1,14 +1,8 @@
1import { useCallback, useMemo, useRef, useState } from "react";1import { useCallback, useMemo, useRef, useState } from "react";
2import {2import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native";
3 Platform,
4 PlatformColor,
5 Pressable,
6 StyleSheet,
7 View,
8 type AccessibilityActionEvent,
9} from "react-native";
10import { Gesture, GestureDetector } from "react-native-gesture-handler";3import { Gesture, GestureDetector } from "react-native-gesture-handler";
11import { runOnJS } from "react-native-reanimated";4import { runOnJS } from "react-native-reanimated";
5import { useThemeColor } from "../../lib/useThemeColor";
126
13const ACCESSIBILITY_RESIZE_STEP = 24;7const ACCESSIBILITY_RESIZE_STEP = 24;
148
...@@ -28,6 +22,8 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {...@@ -28,6 +22,8 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
28 latestProps.current = props;22 latestProps.current = props;
29 const [hovered, setHovered] = useState(false);23 const [hovered, setHovered] = useState(false);
30 const [dragging, setDragging] = useState(false);24 const [dragging, setDragging] = useState(false);
25 const dividerColor = useThemeColor("--color-border");
26 const activeDividerColor = useThemeColor("--color-primary");
31 const handleResizeStart = useCallback(() => {27 const handleResizeStart = useCallback(() => {
32 setDragging(true);28 setDragging(true);
33 latestProps.current.onResizeStart?.();29 latestProps.current.onResizeStart?.();
...@@ -84,7 +80,13 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {...@@ -84,7 +80,13 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
84 onHoverIn={() => setHovered(true)}80 onHoverIn={() => setHovered(true)}
85 onHoverOut={() => setHovered(false)}81 onHoverOut={() => setHovered(false)}
86 >82 >
87 <View style={[styles.line, (hovered || dragging) && styles.activeLine]} />83 <View
84 style={[
85 styles.line,
86 { backgroundColor: dividerColor },
87 (hovered || dragging) && [styles.activeLine, { backgroundColor: activeDividerColor }],
88 ]}
89 />
88 </Pressable>90 </Pressable>
89 </GestureDetector>91 </GestureDetector>
90 );92 );
...@@ -93,14 +95,11 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {...@@ -93,14 +95,11 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
93const styles = StyleSheet.create({95const styles = StyleSheet.create({
94 line: {96 line: {
95 alignSelf: "center",97 alignSelf: "center",
96 backgroundColor:
97 Platform.OS === "ios" ? PlatformColor("separator") : "rgba(120, 120, 128, 0.28)",
98 height: "100%",98 height: "100%",
99 opacity: 0.7,99 opacity: 0.7,
100 width: StyleSheet.hairlineWidth,100 width: StyleSheet.hairlineWidth,
101 },101 },
102 activeLine: {102 activeLine: {
103 backgroundColor: Platform.OS === "ios" ? PlatformColor("systemBlueColor") : "#0a84ff",
104 opacity: 1,103 opacity: 1,
105 width: 2,104 width: 2,
106 },105 },
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+4-12
...@@ -2,14 +2,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"...@@ -2,14 +2,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"
2import { TextInputWrapper } from "expo-paste-input";2import { TextInputWrapper } from "expo-paste-input";
3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import { useCallback, useEffect, useMemo, useState } from "react";4import { useCallback, useEffect, useMemo, useState } from "react";
5import {5import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
6 Platform,
7 Pressable,
8 ScrollView,
9 View,
10 useColorScheme,
11 useWindowDimensions,
12} from "react-native";
13import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";6import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
14import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
15import ImageViewing from "react-native-image-viewing";8import ImageViewing from "react-native-image-viewing";
...@@ -33,10 +26,10 @@ import {...@@ -33,10 +26,10 @@ import {
33 useReviewCommentTarget,26 useReviewCommentTarget,
34} from "./reviewCommentSelection";27} from "./reviewCommentSelection";
35import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";28import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
29import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
36import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRendering";30import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRendering";
37import {31import {
38 highlightReviewSelectedLines,32 highlightReviewSelectedLines,
39 type ReviewDiffTheme,
40 type ReviewHighlightedToken,33 type ReviewHighlightedToken,
41} from "./shikiReviewHighlighter";34} from "./shikiReviewHighlighter";
4235
...@@ -52,7 +45,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp...@@ -52,7 +45,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
52 const navigation = useNavigation();45 const navigation = useNavigation();
53 const insets = useSafeAreaInsets();46 const insets = useSafeAreaInsets();
54 const { width } = useWindowDimensions();47 const { width } = useWindowDimensions();
55 const colorScheme = useColorScheme();48 const { themeAppearance: selectedTheme } = useAppearancePreferences();
56 const iconTint = String(useThemeColor("--color-icon"));49 const iconTint = String(useThemeColor("--color-icon"));
57 const target = useReviewCommentTarget();50 const target = useReviewCommentTarget();
58 const { codeSurface } = useAppearanceCodeSurface();51 const { codeSurface } = useAppearanceCodeSurface();
...@@ -72,7 +65,6 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp...@@ -72,7 +65,6 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
72 const lastLine = selectedLines[selectedLines.length - 1] ?? null;65 const lastLine = selectedLines[selectedLines.length - 1] ?? null;
73 const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;66 const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;
74 const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;67 const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;
75 const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
76 const canSubmit =68 const canSubmit =
77 commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId;69 commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId;
78 const selectionLabel =70 const selectionLabel =
...@@ -162,7 +154,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp...@@ -162,7 +154,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
162 }, [attachments, commentText, dismissComposer, environmentId, target, threadId]);154 }, [attachments, commentText, dismissComposer, environmentId, target, threadId]);
163155
164 return (156 return (
165 <View className="flex-1">157 <View className="flex-1 bg-sheet">
166 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">158 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">
167 <View159 <View
168 className="flex-1 px-5"160 className="flex-1 px-5"
apps/mobile/src/features/review/ReviewSheet.tsx+13-14
...@@ -26,7 +26,6 @@ import {...@@ -26,7 +26,6 @@ import {
26 ScrollView,26 ScrollView,
27 type NativeSyntheticEvent,27 type NativeSyntheticEvent,
28 StyleSheet,28 StyleSheet,
29 useColorScheme,
30 View,29 View,
31} from "react-native";30} from "react-native";
32import { useSafeAreaInsets } from "react-native-safe-area-context";31import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -42,6 +41,7 @@ import { useThemeColor } from "../../lib/useThemeColor";...@@ -42,6 +41,7 @@ import { useThemeColor } from "../../lib/useThemeColor";
42import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";41import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
43import { useThreadDraftForThread } from "../../state/use-thread-composer-state";42import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
44import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";43import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
44import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
45import {45import {
46 useAdaptiveWorkspaceLayout,46 useAdaptiveWorkspaceLayout,
47 useAdaptiveWorkspacePaneRole,47 useAdaptiveWorkspacePaneRole,
...@@ -73,7 +73,7 @@ import { resolveReviewAvailability } from "./reviewAvailability";...@@ -73,7 +73,7 @@ import { resolveReviewAvailability } from "./reviewAvailability";
73import { resolveSelectedReviewFileId } from "./reviewPaneSelection";73import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
74import { buildReviewSectionMenu } from "./review-section-menu";74import { buildReviewSectionMenu } from "./review-section-menu";
75import type { ReviewSectionItem } from "./reviewModel";75import type { ReviewSectionItem } from "./reviewModel";
76import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene";76import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal";
7777
78const REVIEW_HEADER_SPACING = 0;78const REVIEW_HEADER_SPACING = 0;
79const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";79const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
...@@ -97,6 +97,7 @@ function ReviewSelectionActionBar(props: {...@@ -97,6 +97,7 @@ function ReviewSelectionActionBar(props: {
97 readonly onOpenComment: (() => void) | null;97 readonly onOpenComment: (() => void) | null;
98 readonly onClear: () => void;98 readonly onClear: () => void;
99}) {99}) {
100 const foreground = useThemeColor("--color-primary-foreground");
100 if (!props.title) {101 if (!props.title) {
101 return null;102 return null;
102 }103 }
...@@ -106,10 +107,10 @@ function ReviewSelectionActionBar(props: {...@@ -106,10 +107,10 @@ function ReviewSelectionActionBar(props: {
106 <SymbolView107 <SymbolView
107 name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"}108 name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"}
108 size={16}109 size={16}
109 tintColor="#ffffff"110 tintColor={foreground}
110 type="monochrome"111 type="monochrome"
111 />112 />
112 <Text className="text-base font-t3-bold text-white">{props.title}</Text>113 <Text className="text-base font-t3-bold text-primary-foreground">{props.title}</Text>
113 </>114 </>
114 );115 );
115116
...@@ -128,22 +129,22 @@ function ReviewSelectionActionBar(props: {...@@ -128,22 +129,22 @@ function ReviewSelectionActionBar(props: {
128 >129 >
129 {props.onOpenComment ? (130 {props.onOpenComment ? (
130 <Pressable131 <Pressable
131 className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-blue-600 px-5"132 className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-primary px-5"
132 onPress={props.onOpenComment}133 onPress={props.onOpenComment}
133 >134 >
134 {content}135 {content}
135 </Pressable>136 </Pressable>
136 ) : (137 ) : (
137 <View className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-blue-600 px-5">138 <View className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-primary px-5">
138 {content}139 {content}
139 </View>140 </View>
140 )}141 )}
141142
142 <Pressable143 <Pressable
143 className="h-12 w-12 items-center justify-center rounded-full bg-blue-600"144 className="h-12 w-12 items-center justify-center rounded-full bg-primary"
144 onPress={props.onClear}145 onPress={props.onClear}
145 >146 >
146 <SymbolView name="xmark" size={16} tintColor="#ffffff" type="monochrome" />147 <SymbolView name="xmark" size={16} tintColor={foreground} type="monochrome" />
147 </Pressable>148 </Pressable>
148 </View>149 </View>
149 );150 );
...@@ -346,7 +347,7 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -346,7 +347,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
346 const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();347 const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
347 const navigation = useNavigation();348 const navigation = useNavigation();
348 const insets = useSafeAreaInsets();349 const insets = useSafeAreaInsets();
349 const colorScheme = useColorScheme();350 const { themeAppearance: selectedTheme } = useAppearancePreferences();
350 const headerIcon = String(useThemeColor("--color-icon"));351 const headerIcon = String(useThemeColor("--color-icon"));
351 const { environmentId, threadId } = props.route.params;352 const { environmentId, threadId } = props.route.params;
352 const environment = useEnvironmentPresentation(environmentId);353 const environment = useEnvironmentPresentation(environmentId);
...@@ -371,7 +372,6 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -371,7 +372,6 @@ export function ReviewSheet(props: ReviewSheetProps) {
371 // selected thread (it always does when reached from the thread's toolbar).372 // selected thread (it always does when reached from the thread's toolbar).
372 const gitMenuAvailable =373 const gitMenuAvailable =
373 selectedThread !== null && String(selectedThread.id) === String(threadId);374 selectedThread !== null && String(selectedThread.id) === String(threadId);
374 const selectedTheme = colorScheme === "dark" ? "dark" : "light";
375 // With a solid (non-overlay) header the content lays out below the header375 // With a solid (non-overlay) header the content lays out below the header
376 // natively, so no manual top inset is needed. (Android renders its own376 // natively, so no manual top inset is needed. (Android renders its own
377 // in-flow AndroidScreenHeader, so it needs no inset either.)377 // in-flow AndroidScreenHeader, so it needs no inset either.)
...@@ -436,7 +436,6 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -436,7 +436,6 @@ export function ReviewSheet(props: ReviewSheetProps) {
436 sectionId: selectedSection?.id ?? null,436 sectionId: selectedSection?.id ?? null,
437 diff: selectedSection?.diff,437 diff: selectedSection?.diff,
438 data: nativeReviewDiffData,438 data: nativeReviewDiffData,
439 scheme: selectedTheme,
440 collapsedFileIds,439 collapsedFileIds,
441 viewedFileIds,440 viewedFileIds,
442 selectedRowIds: commentSelection.selectedRowIds,441 selectedRowIds: commentSelection.selectedRowIds,
...@@ -444,7 +443,7 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -444,7 +443,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
444 });443 });
445 const showcaseReviewKey =444 const showcaseReviewKey =
446 SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection445 SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection
447 ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}`446 ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}:${nativeBridge.themeId}`
448 : null;447 : null;
449 const handleNativeDebug = useCallback(448 const handleNativeDebug = useCallback(
450 (event: NativeSyntheticEvent<Record<string, unknown>>) => {449 (event: NativeSyntheticEvent<Record<string, unknown>>) => {
...@@ -457,9 +456,9 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -457,9 +456,9 @@ export function ReviewSheet(props: ReviewSheetProps) {
457 return;456 return;
458 }457 }
459 showcasedReviewDrawRef.current = showcaseReviewKey;458 showcasedReviewDrawRef.current = showcaseReviewKey;
460 markNativeShowcaseReady("review");459 reportShowcaseSceneRendered({ scene: "review", themeId: nativeBridge.themeId });
461 },460 },
462 [nativeBridge.onDebug, showcaseReviewKey],461 [nativeBridge.onDebug, nativeBridge.themeId, showcaseReviewKey],
463 );462 );
464463
465 const handleSelectFile = useCallback(464 const handleSelectFile = useCallback(
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+25
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { MOBILE_THEME_IDS } from "../../lib/mobileTheme";
23
3import {4import {
5 createNativeReviewDiffTheme,
4 getCachedNativeReviewDiffData,6 getCachedNativeReviewDiffData,
5 type BuildNativeReviewDiffDataInput,7 type BuildNativeReviewDiffDataInput,
6} from "./nativeReviewDiffAdapter";8} from "./nativeReviewDiffAdapter";
...@@ -54,3 +56,26 @@ describe("getCachedNativeReviewDiffData", () => {...@@ -54,3 +56,26 @@ describe("getCachedNativeReviewDiffData", () => {
54 expect(changed).not.toBe(first);56 expect(changed).not.toBe(first);
55 });57 });
56});58});
59
60describe("createNativeReviewDiffTheme", () => {
61 it("serializes every native color as cross-platform opaque hex", () => {
62 for (const themeId of MOBILE_THEME_IDS) {
63 for (const appearance of ["light", "dark"] as const) {
64 const theme = createNativeReviewDiffTheme(appearance, themeId);
65 for (const color of Object.values(theme)) {
66 expect(color, `${themeId}/${appearance}`).toMatch(/^#[\da-f]{6}$/i);
67 }
68 }
69 }
70 });
71
72 it("uses the selected app palette for native code surfaces", () => {
73 const standard = createNativeReviewDiffTheme("dark", "t3-code");
74 const iris = createNativeReviewDiffTheme("dark", "iris");
75
76 expect(iris.background).not.toBe(standard.background);
77 expect(iris.hunkText).not.toBe(standard.hunkText);
78 expect(iris.addBar).toBe(standard.addBar);
79 expect(iris.deleteBar).toBe(standard.deleteBar);
80 });
81});
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+49-17
...@@ -8,7 +8,12 @@ import { pipe } from "effect/Function";...@@ -8,7 +8,12 @@ import { pipe } from "effect/Function";
8import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";8import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
9import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";9import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
10import { MOBILE_CODE_SURFACE } from "../../lib/typography";10import { MOBILE_CODE_SURFACE } from "../../lib/typography";
11import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";11import {
12 DEFAULT_MOBILE_THEME_ID,
13 getMobileThemeVariables,
14 type MobileThemeId,
15} from "../../lib/mobileTheme";
16import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
12import { computeWordAltDiffRanges } from "./reviewWordDiffs";17import { computeWordAltDiffRanges } from "./reviewWordDiffs";
13import {18import {
14 getReviewFilePreviewState,19 getReviewFilePreviewState,
...@@ -20,6 +25,9 @@ import type { ReviewInlineComment } from "./reviewCommentSelection";...@@ -20,6 +25,9 @@ import type { ReviewInlineComment } from "./reviewCommentSelection";
2025
21const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;26const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;
22const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;27const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;
28const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;
29const NATIVE_RGBA_COLOR =
30 /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/;
2331
24export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;32export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
25export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;33export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
...@@ -28,6 +36,23 @@ export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle(...@@ -28,6 +36,23 @@ export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle(
28 resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),36 resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
29);37);
3038
39function opaqueNativeHexColor(color: string, background: string): string {
40 const hex = NATIVE_HEX_COLOR.exec(color);
41 if (hex) return color;
42
43 const rgba = NATIVE_RGBA_COLOR.exec(color);
44 const backgroundHex = NATIVE_HEX_COLOR.exec(background);
45 if (!rgba || !backgroundHex) return background;
46
47 const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4])));
48 const channels = [1, 2, 3].map((index) => {
49 const foreground = Number(rgba[index]);
50 const behind = Number.parseInt(backgroundHex[index], 16);
51 return Math.round(foreground * alpha + behind * (1 - alpha));
52 });
53 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
54}
55
31export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) {56export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) {
32 return {57 return {
33 rowHeight: codeSurface.rowHeight,58 rowHeight: codeSurface.rowHeight,
...@@ -112,21 +137,28 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment...@@ -112,21 +137,28 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment
112137
113export function createNativeReviewDiffTheme(138export function createNativeReviewDiffTheme(
114 scheme: TerminalAppearanceScheme,139 scheme: TerminalAppearanceScheme,
140 themeId: MobileThemeId = DEFAULT_MOBILE_THEME_ID,
115): NativeReviewDiffTheme {141): NativeReviewDiffTheme {
116 const terminalTheme = getPierreTerminalTheme(scheme);142 const terminalTheme = getMobileTerminalTheme(themeId, scheme);
117 const [, terminalRed, , , terminalBlue] = terminalTheme.palette;143 const appTheme = getMobileThemeVariables(themeId, scheme);
144 const [, terminalRed] = terminalTheme.palette;
145 // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB.
146 // Flatten translucent app tokens onto the code surface so both native
147 // implementations receive the one unambiguous shared format.
148 const background = opaqueNativeHexColor(appTheme["--color-sheet"], appTheme["--color-screen"]);
149 const nativeColor = (color: string) => opaqueNativeHexColor(color, background);
118150
119 if (scheme === "dark") {151 if (scheme === "dark") {
120 return {152 return {
121 // Match the app surface (--color-sheet) so code views blend with the rest of153 // Match the app surface (--color-sheet) so code views blend with the rest of
122 // the app instead of using a distinct code-editor background.154 // the app instead of using a distinct code-editor background.
123 background: "#0e0e0e",155 background,
124 text: terminalTheme.foreground,156 text: nativeColor(appTheme["--color-md-code-text"]),
125 mutedText: terminalTheme.mutedForeground,157 mutedText: nativeColor(appTheme["--color-foreground-muted"]),
126 headerBackground: "#0e0e0e",158 headerBackground: background,
127 border: terminalTheme.border,159 border: nativeColor(appTheme["--color-border"]),
128 hunkBackground: "#071f28",160 hunkBackground: nativeColor(appTheme["--color-subtle-strong"]),
129 hunkText: terminalBlue ?? "#009fff",161 hunkText: nativeColor(appTheme["--color-primary"]),
130 addBackground: "#0d2f28",162 addBackground: "#0d2f28",
131 deleteBackground: "#391415",163 deleteBackground: "#391415",
132 addBar: "#00cab1",164 addBar: "#00cab1",
...@@ -139,13 +171,13 @@ export function createNativeReviewDiffTheme(...@@ -139,13 +171,13 @@ export function createNativeReviewDiffTheme(
139 return {171 return {
140 // Match the app surface (--color-sheet) so code views blend with the rest of the172 // Match the app surface (--color-sheet) so code views blend with the rest of the
141 // app instead of using a distinct code-editor background.173 // app instead of using a distinct code-editor background.
142 background: "#f2f2f7",174 background,
143 text: "#070707",175 text: nativeColor(appTheme["--color-md-code-text"]),
144 mutedText: terminalTheme.mutedForeground,176 mutedText: nativeColor(appTheme["--color-foreground-muted"]),
145 headerBackground: "#f2f2f7",177 headerBackground: background,
146 border: terminalTheme.border,178 border: nativeColor(appTheme["--color-border"]),
147 hunkBackground: "#e0f2ff",179 hunkBackground: nativeColor(appTheme["--color-subtle-strong"]),
148 hunkText: terminalBlue ?? "#009fff",180 hunkText: nativeColor(appTheme["--color-primary"]),
149 addBackground: "#e5f8f5",181 addBackground: "#e5f8f5",
150 deleteBackground: "#ffe6e7",182 deleteBackground: "#ffe6e7",
151 addBar: "#00cab1",183 addBar: "#00cab1",
apps/mobile/src/features/review/useNativeReviewDiffBridge.ts+4-4
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import { useCallback, useMemo, useState } from "react";1import { useCallback, useMemo, useState } from "react";
2import type { NativeSyntheticEvent } from "react-native";2import type { NativeSyntheticEvent } from "react-native";
33
4import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
5import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter";4import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter";
6import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";5import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
6import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
7import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";7import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";
8import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys";8import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys";
99
...@@ -14,7 +14,6 @@ export function useNativeReviewDiffBridge(input: {...@@ -14,7 +14,6 @@ export function useNativeReviewDiffBridge(input: {
14 readonly sectionId: string | null;14 readonly sectionId: string | null;
15 readonly diff: string | null | undefined;15 readonly diff: string | null | undefined;
16 readonly data: NativeReviewDiffData;16 readonly data: NativeReviewDiffData;
17 readonly scheme: NativeReviewDiffHighlightScheme;
18 readonly collapsedFileIds: ReadonlyArray<string>;17 readonly collapsedFileIds: ReadonlyArray<string>;
19 readonly viewedFileIds: ReadonlyArray<string>;18 readonly viewedFileIds: ReadonlyArray<string>;
20 readonly selectedRowIds: ReadonlyArray<string>;19 readonly selectedRowIds: ReadonlyArray<string>;
...@@ -25,18 +24,18 @@ export function useNativeReviewDiffBridge(input: {...@@ -25,18 +24,18 @@ export function useNativeReviewDiffBridge(input: {
25 collapsedFileIds,24 collapsedFileIds,
26 data,25 data,
27 diff,26 diff,
28 scheme,
29 sectionId,27 sectionId,
30 selectedRowIds,28 selectedRowIds,
31 threadKey,29 threadKey,
32 viewedFileIds,30 viewedFileIds,
33 } = input;31 } = input;
34 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();32 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
33 const { themeAppearance: scheme, themeId } = useAppearancePreferences();
35 const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>(34 const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>(
36 () => new Set(),35 () => new Set(),
37 );36 );
3837
39 const theme = useMemo(() => createNativeReviewDiffTheme(scheme), [scheme]);38 const theme = useMemo(() => createNativeReviewDiffTheme(scheme, themeId), [scheme, themeId]);
40 const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]);39 const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]);
41 const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]);40 const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]);
42 const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]);41 const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]);
...@@ -106,6 +105,7 @@ export function useNativeReviewDiffBridge(input: {...@@ -106,6 +105,7 @@ export function useNativeReviewDiffBridge(input: {
106 );105 );
107106
108 return {107 return {
108 themeId,
109 theme,109 theme,
110 rowsJson,110 rowsJson,
111 collapsedFileIdsJson,111 collapsedFileIdsJson,
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+2
...@@ -7,6 +7,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";...@@ -7,6 +7,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
7import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";7import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
8import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";8import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
9import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";9import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";
10import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection";
1011
11export function SettingsAppearanceRouteScreen() {12export function SettingsAppearanceRouteScreen() {
12 const navigation = useNavigation();13 const navigation = useNavigation();
...@@ -29,6 +30,7 @@ export function SettingsAppearanceRouteScreen() {...@@ -29,6 +30,7 @@ export function SettingsAppearanceRouteScreen() {
29 paddingBottom: Math.max(insets.bottom, 18) + 18,30 paddingBottom: Math.max(insets.bottom, 18) + 18,
30 }}31 }}
31 >32 >
33 <ThemeAppearanceSection />
32 <TextAppearanceSection />34 <TextAppearanceSection />
33 <TerminalAppearanceSection />35 <TerminalAppearanceSection />
34 <CodeAppearanceSection />36 <CodeAppearanceSection />
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+1-8
...@@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac...@@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
2import { useNavigation } from "@react-navigation/native";2import { useNavigation } from "@react-navigation/native";
3import { SymbolView } from "../../components/AppSymbol";3import { SymbolView } from "../../components/AppSymbol";
4import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
5import { useCallback, useEffect, useState } from "react";5import { useCallback, useState } from "react";
6import { Platform, ScrollView, View } from "react-native";6import { Platform, ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
88
...@@ -20,7 +20,6 @@ import {...@@ -20,7 +20,6 @@ import {
20 SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,20 SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
21 SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS,21 SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS,
22} from "../showcase/showcaseEnvironmentRows";22} from "../showcase/showcaseEnvironmentRows";
23import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene";
2423
25const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";24const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
2625
...@@ -48,12 +47,6 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -48,12 +47,6 @@ export function SettingsEnvironmentsRouteScreen() {
48 const accentColor = useThemeColor("--color-icon-muted");47 const accentColor = useThemeColor("--color-icon-muted");
49 const headerIconColor = useThemeColor("--color-icon");48 const headerIconColor = useThemeColor("--color-icon");
5049
51 useEffect(() => {
52 if (!SHOWCASE_ENABLED) return;
53 const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500);
54 return () => clearTimeout(timer);
55 }, []);
56
57 const handleToggle = useCallback((environmentId: EnvironmentId) => {50 const handleToggle = useCallback((environmentId: EnvironmentId) => {
58 setExpandedId((prev) => (prev === environmentId ? null : environmentId));51 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
59 }, []);52 }, []);
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+101-19
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { createContext, use, useCallback, useEffect, useMemo, type ReactNode } from "react";1import { createContext, use, useCallback, useLayoutEffect, useMemo, type ReactNode } from "react";
2import { useColorScheme } from "react-native";
23
3import { useAtomSet, useAtomValue } from "@effect/atom-react";4import { useAtomSet, useAtomValue } from "@effect/atom-react";
4import { AsyncResult } from "effect/unstable/reactivity";5import { AsyncResult } from "effect/unstable/reactivity";
...@@ -9,16 +10,37 @@ import {...@@ -9,16 +10,37 @@ import {
9 resolveAppearance,10 resolveAppearance,
10 resolveAppearancePreferences,11 resolveAppearancePreferences,
11 resolveTextScaleVariables,12 resolveTextScaleVariables,
12 type AppearancePreferences,
13 type ResolvedAppearance,13 type ResolvedAppearance,
14} from "../../../lib/appearancePreferences";14} from "../../../lib/appearancePreferences";
15import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";15import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
16import type { Preferences } from "../../../persistence/mobile-preferences";
17import {
18 createMobileThemePairPatch,
19 createMobileThemeSelectionPatch,
20 getMobileThemeVariables,
21 normalizeMobileThemeMode,
22 resolveMobileThemeIds,
23 type MobileThemeAppearance,
24 type MobileThemeId,
25 type MobileThemeIds,
26 type MobileThemeMode,
27} from "../../../lib/mobileTheme";
16import { cacheTerminalFontSize } from "../../terminal/terminalUiState";28import { cacheTerminalFontSize } from "../../terminal/terminalUiState";
1729
18interface AppearancePreferencesContextValue {30interface AppearancePreferencesContextValue {
19 /** Effective values with base-size derivation applied. Use this for rendering. */31 /** Effective values with base-size derivation applied. Use this for rendering. */
20 readonly appearance: ResolvedAppearance;32 readonly appearance: ResolvedAppearance;
33 readonly themeId: MobileThemeId;
34 readonly themeIds: MobileThemeIds;
35 readonly themeMode: MobileThemeMode;
36 readonly themeAppearance: MobileThemeAppearance;
21 readonly isReady: boolean;37 readonly isReady: boolean;
38 readonly setThemeIdForAppearance: (
39 appearance: MobileThemeAppearance,
40 value: MobileThemeId,
41 ) => void;
42 readonly setThemeIdForBothAppearances: (value: MobileThemeId) => void;
43 readonly setThemeMode: (value: MobileThemeMode) => void;
22 readonly setBaseFontSize: (value: number) => void;44 readonly setBaseFontSize: (value: number) => void;
23 /** Pass null to clear the override and follow the base font size. */45 /** Pass null to clear the override and follow the base font size. */
24 readonly setTerminalFontSize: (value: number | null) => void;46 readonly setTerminalFontSize: (value: number | null) => void;
...@@ -30,46 +52,85 @@ interface AppearancePreferencesContextValue {...@@ -30,46 +52,85 @@ interface AppearancePreferencesContextValue {
30const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null);52const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null);
3153
32/**54/**
33 * Injects the scaled `--text-*` variables into Uniwind so every55 * Injects palette and text-scale variables into both adaptive stylesheets.
34 * className-based text size (`text-sm`, `text-base`, ...) re-resolves live.56 * Updating the active sheet last lets the visible app settle in one pass.
35 * Updates the current theme last so the active stylesheet settles correctly.
36 */57 */
37function applyTextScaleVariables(baseFontSize: number) {58function applyAppearanceVariables(baseFontSize: number, themeIds: MobileThemeIds) {
38 const variables = resolveTextScaleVariables(baseFontSize);59 const textVariables = resolveTextScaleVariables(baseFontSize);
39 const currentTheme = Uniwind.currentTheme;60 const currentTheme = Uniwind.currentTheme;
61 const activeAppearance =
62 currentTheme === "light" || currentTheme === "dark" ? currentTheme : null;
4063
41 for (const theme of ["light", "dark"] as const) {64 for (const theme of ["light", "dark"] as const) {
42 if (theme !== currentTheme) {65 const variables = { ...getMobileThemeVariables(themeIds[theme], theme), ...textVariables };
66 if (theme !== activeAppearance) {
43 Uniwind.updateCSSVariables(theme, variables);67 Uniwind.updateCSSVariables(theme, variables);
44 }68 }
45 }69 }
46 Uniwind.updateCSSVariables(currentTheme, variables);70 if (activeAppearance !== null) {
71 Uniwind.updateCSSVariables(activeAppearance, {
72 ...getMobileThemeVariables(themeIds[activeAppearance], activeAppearance),
73 ...textVariables,
74 });
75 }
47}76}
4877
49export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {78export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
50 const preferencesResult = useAtomValue(mobilePreferencesAtom);79 const preferencesResult = useAtomValue(mobilePreferencesAtom);
51 const savePreferences = useAtomSet(updateMobilePreferencesAtom);80 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
81 const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light";
82 const storedPreferences = AsyncResult.isSuccess(preferencesResult)
83 ? preferencesResult.value
84 : null;
52 const preferences = useMemo(85 const preferences = useMemo(
53 () =>86 () => resolveAppearancePreferences(storedPreferences),
54 resolveAppearancePreferences(87 [storedPreferences],
55 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null,88 );
56 ),89 const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode);
57 [preferencesResult],90 const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode;
91 const themeIds = useMemo(
92 () => resolveMobileThemeIds(storedPreferences ?? {}),
93 [storedPreferences],
58 );94 );
95 const themeId = themeIds[themeAppearance];
59 const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;96 const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
6097
61 useEffect(() => {98 useLayoutEffect(() => {
62 applyTextScaleVariables(preferences.baseFontSize);99 applyAppearanceVariables(preferences.baseFontSize, themeIds);
100 Uniwind.setTheme(themeMode);
63 cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize);101 cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize);
64 }, [preferences]);102 }, [preferences, themeIds, themeMode]);
65103
66 const updatePreferences = useCallback(104 const updatePreferences = useCallback(
67 (patch: Partial<AppearancePreferences>) => {105 (patch: Partial<Preferences>) => {
68 savePreferences(patch);106 savePreferences(patch);
69 },107 },
70 [savePreferences],108 [savePreferences],
71 );109 );
72110
111 const setThemeIdForAppearance = useCallback(
112 (appearance: MobileThemeAppearance, value: MobileThemeId) => {
113 updatePreferences(
114 createMobileThemeSelectionPatch(themeIds, themeAppearance, appearance, value),
115 );
116 },
117 [themeAppearance, themeIds, updatePreferences],
118 );
119
120 const setThemeIdForBothAppearances = useCallback(
121 (value: MobileThemeId) => {
122 updatePreferences(createMobileThemePairPatch(value));
123 },
124 [updatePreferences],
125 );
126
127 const setThemeMode = useCallback(
128 (value: MobileThemeMode) => {
129 updatePreferences({ themeMode: value });
130 },
131 [updatePreferences],
132 );
133
73 const setBaseFontSize = useCallback(134 const setBaseFontSize = useCallback(
74 (value: number) => {135 (value: number) => {
75 updatePreferences({ baseFontSize: value });136 updatePreferences({ baseFontSize: value });
...@@ -101,13 +162,34 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -101,13 +162,34 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
101 const value = useMemo(162 const value = useMemo(
102 (): AppearancePreferencesContextValue => ({163 (): AppearancePreferencesContextValue => ({
103 appearance: resolveAppearance(preferences),164 appearance: resolveAppearance(preferences),
165 themeId,
166 themeIds,
167 themeMode,
168 themeAppearance,
104 isReady,169 isReady,
170 setThemeIdForAppearance,
171 setThemeIdForBothAppearances,
172 setThemeMode,
105 setBaseFontSize,173 setBaseFontSize,
106 setTerminalFontSize,174 setTerminalFontSize,
107 setCodeFontSize,175 setCodeFontSize,
108 setCodeWordBreak,176 setCodeWordBreak,
109 }),177 }),
110 [preferences, isReady, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak],178 [
179 preferences,
180 themeId,
181 themeIds,
182 themeMode,
183 themeAppearance,
184 isReady,
185 setThemeIdForAppearance,
186 setThemeIdForBothAppearances,
187 setThemeMode,
188 setBaseFontSize,
189 setTerminalFontSize,
190 setCodeFontSize,
191 setCodeWordBreak,
192 ],
111 );193 );
112194
113 return (195 return (
apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx+5-11
...@@ -1,11 +1,4 @@...@@ -1,11 +1,4 @@
1import {1import { Platform, ScrollView, type StyleProp, type TextStyle, View } from "react-native";
2 Platform,
3 ScrollView,
4 type StyleProp,
5 type TextStyle,
6 View,
7 useColorScheme,
8} from "react-native";
92
10import { AppText as Text } from "../../../../components/AppText";3import { AppText as Text } from "../../../../components/AppText";
11import {4import {
...@@ -13,7 +6,8 @@ import {...@@ -13,7 +6,8 @@ import {
13 resolveMobileCodeSurface,6 resolveMobileCodeSurface,
14} from "../../../../lib/appearancePreferences";7} from "../../../../lib/appearancePreferences";
15import { useThemeColor } from "../../../../lib/useThemeColor";8import { useThemeColor } from "../../../../lib/useThemeColor";
16import { getPierreTerminalTheme } from "../../../terminal/terminalTheme";9import { getMobileTerminalTheme } from "../../../terminal/terminalTheme";
10import { useAppearancePreferences } from "../AppearancePreferencesProvider";
1711
18const CODE_FONT_FAMILY = Platform.select({12const CODE_FONT_FAMILY = Platform.select({
19 ios: "ui-monospace",13 ios: "ui-monospace",
...@@ -53,8 +47,8 @@ export function TextAppearancePreview(props: { readonly fontSize: number }) {...@@ -53,8 +47,8 @@ export function TextAppearancePreview(props: { readonly fontSize: number }) {
53 * on the shared card background so it reads like the other previews.47 * on the shared card background so it reads like the other previews.
54 */48 */
55export function TerminalAppearancePreview(props: { readonly fontSize: number }) {49export function TerminalAppearancePreview(props: { readonly fontSize: number }) {
56 const scheme = useColorScheme() === "light" ? "light" : "dark";50 const { themeAppearance: scheme, themeId } = useAppearancePreferences();
57 const theme = getPierreTerminalTheme(scheme);51 const theme = getMobileTerminalTheme(themeId, scheme);
58 const lineHeight = Math.round(props.fontSize * 1.6);52 const lineHeight = Math.round(props.fontSize * 1.6);
59 const lineStyle = {53 const lineStyle = {
60 fontFamily: "Menlo",54 fontFamily: "Menlo",
apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx created+365
...@@ -0,0 +1,365 @@
1import { memo, useId } from "react";
2import { Pressable, View } from "react-native";
3import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
4
5import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview";
6
7import { SymbolView } from "../../../../components/AppSymbol";
8import { AppText as Text } from "../../../../components/AppText";
9import {
10 getMobileThemeVariables,
11 getMobileThemePreviewColors,
12 MOBILE_THEME_OPTIONS,
13 type MobileThemeAppearance,
14 type MobileThemeId,
15 type MobileThemeIds,
16 type MobileThemeMode,
17 type MobileThemeVariables,
18} from "../../../../lib/mobileTheme";
19import { useThemeColor } from "../../../../lib/useThemeColor";
20import { useAppearancePreferences } from "../AppearancePreferencesProvider";
21
22const APPEARANCE_MODES: ReadonlyArray<{
23 readonly id: MobileThemeMode;
24 readonly label: string;
25}> = [
26 { id: "system", label: "System" },
27 { id: "light", label: "Light" },
28 { id: "dark", label: "Dark" },
29];
30
31const PreviewOrb = memo(function PreviewOrb(props: {
32 readonly appearance: MobileThemeAppearance;
33 readonly compact?: boolean;
34 readonly themeId: MobileThemeId;
35}) {
36 const idPrefix = useId().replaceAll(":", "");
37 const accentGradientId = `${idPrefix}-accent-glow`;
38 const actionGradientId = `${idPrefix}-action-glow`;
39 const colors = getMobileThemePreviewColors(props.themeId, props.appearance);
40 const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance];
41 const accentRadius = Math.hypot(
42 Math.max(spec.accent.center[0], 1 - spec.accent.center[0]),
43 Math.max(spec.accent.center[1], 1 - spec.accent.center[1]),
44 );
45 const actionRadius = Math.hypot(
46 Math.max(spec.action.center[0], 1 - spec.action.center[0]),
47 Math.max(spec.action.center[1], 1 - spec.action.center[1]),
48 );
49 const position = (value: number) => `${value * 100}%`;
50 const radius = (value: number) => `${value * 100}%`;
51
52 return (
53 <View
54 className={`${props.compact ? "size-14" : "size-16"} overflow-hidden rounded-full border`}
55 style={{
56 borderColor:
57 props.appearance === "dark" ? "rgba(255, 255, 255, 0.14)" : "rgba(0, 0, 0, 0.1)",
58 }}
59 >
60 <Svg accessibilityElementsHidden height="100%" viewBox="0 0 64 64" width="100%">
61 <Defs>
62 <RadialGradient
63 cx={position(spec.accent.center[0])}
64 cy={position(spec.accent.center[1])}
65 fx={position(spec.accent.center[0])}
66 fy={position(spec.accent.center[1])}
67 id={accentGradientId}
68 r={radius(accentRadius)}
69 >
70 <Stop offset="0%" stopColor={colors.accent} stopOpacity={1} />
71 <Stop
72 offset={position(spec.accent.middleOffset)}
73 stopColor={colors.accent}
74 stopOpacity={spec.accent.middleOpacity}
75 />
76 <Stop
77 offset={position(spec.accent.endOffset)}
78 stopColor={colors.accent}
79 stopOpacity={0}
80 />
81 <Stop offset="100%" stopColor={colors.accent} stopOpacity={0} />
82 </RadialGradient>
83 <RadialGradient
84 cx={position(spec.action.center[0])}
85 cy={position(spec.action.center[1])}
86 fx={position(spec.action.center[0])}
87 fy={position(spec.action.center[1])}
88 id={actionGradientId}
89 r={radius(actionRadius)}
90 >
91 <Stop
92 offset="0%"
93 stopColor={colors.messageAction}
94 stopOpacity={spec.action.startOpacity}
95 />
96 <Stop
97 offset={position(spec.action.endOffset)}
98 stopColor={colors.messageAction}
99 stopOpacity={0}
100 />
101 <Stop offset="100%" stopColor={colors.messageAction} stopOpacity={0} />
102 </RadialGradient>
103 </Defs>
104 <Circle cx="32" cy="32" fill={mixThemePreviewBase(colors, props.appearance)} r="32" />
105 <Circle cx="32" cy="32" fill={`url(#${actionGradientId})`} r="32" />
106 <Circle cx="32" cy="32" fill={`url(#${accentGradientId})`} r="32" />
107 </Svg>
108 </View>
109 );
110});
111
112function ThemeCard(props: {
113 readonly disabled: boolean;
114 readonly darkSelected: boolean;
115 readonly label: string;
116 readonly lightSelected: boolean;
117 readonly onSelectBoth: () => void;
118 readonly onSelect: (appearance: MobileThemeAppearance) => void;
119 readonly themeId: MobileThemeId;
120}) {
121 const badgeBackground = useThemeColor("--color-card");
122 const badgeIcon = useThemeColor("--color-icon");
123
124 const choice = (appearance: MobileThemeAppearance, selected: boolean) => (
125 <Pressable
126 accessibilityLabel={`${props.label} ${appearance} theme`}
127 accessibilityRole="radio"
128 accessibilityState={{ checked: selected, disabled: props.disabled }}
129 className={
130 selected
131 ? "size-[66px] items-center justify-center rounded-full border-[3px] border-primary"
132 : "size-[66px] items-center justify-center rounded-full border-[3px] border-transparent"
133 }
134 disabled={props.disabled}
135 onPress={() => props.onSelect(appearance)}
136 >
137 <PreviewOrb appearance={appearance} compact themeId={props.themeId} />
138 {selected ? (
139 <View
140 className="absolute -bottom-0.5 -right-0.5 size-5 items-center justify-center rounded-full border border-border"
141 style={{ backgroundColor: badgeBackground }}
142 >
143 <SymbolView
144 name={appearance === "light" ? "sun.max" : "moon"}
145 size={12}
146 tintColor={badgeIcon}
147 type="monochrome"
148 weight="medium"
149 />
150 </View>
151 ) : null}
152 </Pressable>
153 );
154
155 return (
156 <View className="min-w-36 flex-1 basis-[47%] gap-3 rounded-[24px] border border-border bg-card px-2 py-4">
157 <Pressable
158 accessibilityHint="Sets both light and dark appearances"
159 accessibilityLabel={`${props.label} theme`}
160 accessibilityRole="button"
161 accessibilityState={{ disabled: props.disabled }}
162 className="absolute inset-0 rounded-[24px] active:bg-subtle"
163 disabled={props.disabled}
164 onPress={props.onSelectBoth}
165 />
166 <View className="flex-row items-center justify-center gap-2 py-1" pointerEvents="box-none">
167 {choice("light", props.lightSelected)}
168 {choice("dark", props.darkSelected)}
169 </View>
170 <View className="min-h-8 flex-row items-center" pointerEvents="none">
171 <Text className="min-w-0 flex-1 text-lg font-t3-medium" numberOfLines={1}>
172 {props.label}
173 </Text>
174 </View>
175 </View>
176 );
177}
178
179function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly compact?: boolean }) {
180 return (
181 <View
182 className="flex-1 overflow-hidden"
183 style={{ backgroundColor: props.colors["--color-screen"] }}
184 >
185 <View
186 className={props.compact ? "h-[18px] gap-0.5 px-1" : "h-[18px] gap-1 px-1.5"}
187 style={{ backgroundColor: props.colors["--color-card"] }}
188 >
189 <View className="mt-2 flex-row items-center gap-1">
190 <View
191 className="size-1.5 rounded-full"
192 style={{ backgroundColor: props.colors["--color-primary"] }}
193 />
194 <View
195 className="h-1 flex-1 rounded-full"
196 style={{ backgroundColor: props.colors["--color-foreground-muted"] }}
197 />
198 </View>
199 </View>
200 <View
201 className={
202 props.compact ? "flex-1 justify-between px-1 py-2" : "flex-1 justify-between px-1.5 py-2"
203 }
204 >
205 <View className="gap-1">
206 <View
207 className="h-1.5 w-[72%] rounded-full"
208 style={{ backgroundColor: props.colors["--color-subtle-strong"] }}
209 />
210 <View
211 className="h-1.5 w-[46%] rounded-full"
212 style={{ backgroundColor: props.colors["--color-subtle-strong"] }}
213 />
214 </View>
215 <View className="items-end gap-1 pb-2">
216 <View
217 className="h-3 w-[78%] rounded-full"
218 style={{ backgroundColor: props.colors["--color-user-bubble"] }}
219 />
220 <View
221 className="h-1 w-[38%] rounded-full"
222 style={{ backgroundColor: props.colors["--color-foreground-muted"] }}
223 />
224 </View>
225 </View>
226 </View>
227 );
228}
229
230function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) {
231 const light = getMobileThemeVariables(props.themeIds.light, "light");
232 const dark = getMobileThemeVariables(props.themeIds.dark, "dark");
233 const currentBorder = useThemeColor("--color-border");
234 const currentFrame = useThemeColor("--color-drawer");
235 const currentIndicator = useThemeColor("--color-foreground-muted");
236 const frameColor =
237 props.mode === "light"
238 ? light["--color-border"]
239 : props.mode === "dark"
240 ? dark["--color-border"]
241 : currentBorder;
242 const frameBackground =
243 props.mode === "light"
244 ? light["--color-drawer"]
245 : props.mode === "dark"
246 ? dark["--color-drawer"]
247 : currentFrame;
248 const indicatorColor =
249 props.mode === "light"
250 ? light["--color-foreground-muted"]
251 : props.mode === "dark"
252 ? dark["--color-foreground-muted"]
253 : currentIndicator;
254
255 return (
256 <View
257 className="h-24 w-14 self-center rounded-[16px] p-[3px]"
258 style={{ backgroundColor: frameBackground, borderColor: frameColor, borderWidth: 1.5 }}
259 >
260 <View className="flex-1 flex-row overflow-hidden rounded-[11px]">
261 {props.mode === "system" ? (
262 <>
263 <PreviewPane colors={light} compact />
264 <PreviewPane colors={dark} compact />
265 </>
266 ) : (
267 <PreviewPane colors={props.mode === "light" ? light : dark} />
268 )}
269 </View>
270 <View
271 className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full"
272 style={{ backgroundColor: indicatorColor }}
273 />
274 </View>
275 );
276}
277
278function ModeCard(props: {
279 readonly disabled: boolean;
280 readonly label: string;
281 readonly mode: MobileThemeMode;
282 readonly onPress: () => void;
283 readonly selected: boolean;
284 readonly themeIds: MobileThemeIds;
285}) {
286 return (
287 <Pressable
288 accessibilityLabel={`${props.label} appearance`}
289 accessibilityRole="radio"
290 accessibilityState={{ checked: props.selected, disabled: props.disabled }}
291 className={
292 props.selected
293 ? "min-w-0 flex-1 gap-2 rounded-[24px] border-2 border-primary bg-subtle p-2"
294 : "min-w-0 flex-1 gap-2 rounded-[24px] border border-border bg-card p-2"
295 }
296 disabled={props.disabled}
297 onPress={props.onPress}
298 >
299 <ModePreview mode={props.mode} themeIds={props.themeIds} />
300 <Text
301 className={
302 props.selected
303 ? "text-center text-base font-t3-bold text-foreground"
304 : "text-center text-base text-foreground-muted"
305 }
306 >
307 {props.label}
308 </Text>
309 </Pressable>
310 );
311}
312
313function SectionLabel({ children }: { readonly children: string }) {
314 return <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{children}</Text>;
315}
316
317export function ThemeAppearanceSection() {
318 const {
319 isReady,
320 setThemeIdForAppearance,
321 setThemeIdForBothAppearances,
322 setThemeMode,
323 themeIds,
324 themeMode,
325 } = useAppearancePreferences();
326
327 return (
328 <View className="gap-6">
329 <View className="gap-2">
330 <SectionLabel>Color scheme</SectionLabel>
331 <View accessibilityRole="radiogroup" className="flex-row gap-2">
332 {APPEARANCE_MODES.map((mode) => (
333 <ModeCard
334 disabled={!isReady}
335 key={mode.id}
336 label={mode.label}
337 mode={mode.id}
338 onPress={() => setThemeMode(mode.id)}
339 selected={mode.id === themeMode}
340 themeIds={themeIds}
341 />
342 ))}
343 </View>
344 </View>
345
346 <View className="gap-3">
347 <SectionLabel>Themes</SectionLabel>
348 <View className="flex-row flex-wrap gap-3">
349 {MOBILE_THEME_OPTIONS.map((theme) => (
350 <ThemeCard
351 disabled={!isReady}
352 key={theme.id}
353 label={theme.label}
354 darkSelected={theme.id === themeIds.dark}
355 lightSelected={theme.id === themeIds.light}
356 onSelect={(appearance) => setThemeIdForAppearance(appearance, theme.id)}
357 onSelectBoth={() => setThemeIdForBothAppearances(theme.id)}
358 themeId={theme.id}
359 />
360 ))}
361 </View>
362 </View>
363 </View>
364 );
365}
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+3-6
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import type { ComponentProps } from "react";1import type { ComponentProps } from "react";
2import { Switch, View } from "react-native";2import { View } from "react-native";
33
4import { SymbolView } from "../../../components/AppSymbol";4import { SymbolView } from "../../../components/AppSymbol";
5import { AppText as Text } from "../../../components/AppText";5import { AppText as Text } from "../../../components/AppText";
6import { ThemedSwitch } from "../../../components/ThemedSwitch";
6import { useThemeColor } from "../../../lib/useThemeColor";7import { useThemeColor } from "../../../lib/useThemeColor";
78
8type SymbolName = ComponentProps<typeof SymbolView>["name"];9type SymbolName = ComponentProps<typeof SymbolView>["name"];
...@@ -16,8 +17,6 @@ export function SettingsSwitchRow(props: {...@@ -16,8 +17,6 @@ export function SettingsSwitchRow(props: {
16 readonly onValueChange: (value: boolean) => void;17 readonly onValueChange: (value: boolean) => void;
17}) {18}) {
18 const icon = useThemeColor("--color-icon");19 const icon = useThemeColor("--color-icon");
19 const activeTrack = String(useThemeColor("--color-switch-active"));
20 const track = String(useThemeColor("--color-secondary-border"));
2120
22 return (21 return (
23 <View22 <View
...@@ -34,11 +33,9 @@ export function SettingsSwitchRow(props: {...@@ -34,11 +33,9 @@ export function SettingsSwitchRow(props: {
34 <Text className="text-sm text-foreground-muted">{props.subtitle}</Text>33 <Text className="text-sm text-foreground-muted">{props.subtitle}</Text>
35 ) : null}34 ) : null}
36 </View>35 </View>
37 <Switch36 <ThemedSwitch
38 disabled={props.disabled}37 disabled={props.disabled}
39 ios_backgroundColor={track}
40 onValueChange={props.onValueChange}38 onValueChange={props.onValueChange}
41 trackColor={{ false: track, true: activeTrack }}
42 value={props.value}39 value={props.value}
43 />40 />
44 </View>41 </View>
apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx+65-14
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { useEffect, useRef, useState } from "react";1import { useEffect, useRef, useState, useSyncExternalStore } from "react";
2import { Keyboard, View } from "react-native";2import { Keyboard, View } from "react-native";
3import {3import {
4 CommonActions,4 CommonActions,
...@@ -10,6 +10,8 @@ import {...@@ -10,6 +10,8 @@ import {
10import { AsyncResult } from "effect/unstable/reactivity";10import { AsyncResult } from "effect/unstable/reactivity";
1111
12import { useConnectionController } from "../connection/useConnectionController";12import { useConnectionController } from "../connection/useConnectionController";
13import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
14import type { MobileThemeId } from "../../lib/mobileTheme";
13import { useProjects, useThreadShells } from "../../state/entities";15import { useProjects, useThreadShells } from "../../state/entities";
14import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";16import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
15import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";17import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";
...@@ -19,6 +21,7 @@ import {...@@ -19,6 +21,7 @@ import {
19 getNativeShowcaseOrientation,21 getNativeShowcaseOrientation,
20 getNativeShowcasePairingUrls,22 getNativeShowcasePairingUrls,
21 getNativeShowcaseScene,23 getNativeShowcaseScene,
24 getNativeShowcaseTheme,
22 markNativeShowcaseReady,25 markNativeShowcaseReady,
23 type ShowcaseScene,26 type ShowcaseScene,
24} from "./nativeShowcaseScene";27} from "./nativeShowcaseScene";
...@@ -27,6 +30,12 @@ import {...@@ -27,6 +30,12 @@ import {
27 SHOWCASE_PENDING_TASK_DEFINITIONS,30 SHOWCASE_PENDING_TASK_DEFINITIONS,
28} from "./showcasePendingTasks";31} from "./showcasePendingTasks";
29import { retryShowcaseOperation } from "./showcaseRetry";32import { retryShowcaseOperation } from "./showcaseRetry";
33import {
34 clearShowcaseRenderSignal,
35 getShowcaseRenderSignal,
36 isShowcaseNativeContentReady,
37 subscribeToShowcaseRenderSignal,
38} from "./showcaseRenderSignal";
3039
31const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";40const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
32const SHOWCASE_THREAD_ID = "remote-command-center";41const SHOWCASE_THREAD_ID = "remote-command-center";
...@@ -48,6 +57,12 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null {...@@ -48,6 +57,12 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null {
48export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) {57export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) {
49 const navigation = useNavigation();58 const navigation = useNavigation();
50 const { connectPairingUrl } = useConnectionController();59 const { connectPairingUrl } = useConnectionController();
60 const {
61 isReady: appearancePreferencesReady,
62 themeId,
63 themeIds,
64 setThemeIdForBothAppearances,
65 } = useAppearancePreferences();
51 const workspace = useWorkspaceState();66 const workspace = useWorkspaceState();
52 const projects = useProjects();67 const projects = useProjects();
53 const threads = useThreadShells();68 const threads = useThreadShells();
...@@ -56,18 +71,32 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })...@@ -56,18 +71,32 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
56 const [pairingUrls, setPairingUrls] = useState<ReadonlyArray<string>>([]);71 const [pairingUrls, setPairingUrls] = useState<ReadonlyArray<string>>([]);
57 const [pendingTasksReady, setPendingTasksReady] = useState(false);72 const [pendingTasksReady, setPendingTasksReady] = useState(false);
58 const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null);73 const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null);
74 const [requestedTheme, setRequestedTheme] = useState<MobileThemeId | null>(null);
75 const [themeRequestSettled, setThemeRequestSettled] = useState(false);
59 const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);76 const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);
60 const [orientationSettled, setOrientationSettled] = useState(false);77 const [orientationSettled, setOrientationSettled] = useState(false);
78 const requestedSceneRef = useRef<ShowcaseScene | null>(null);
79 const renderSignal = useSyncExternalStore(
80 subscribeToShowcaseRenderSignal,
81 getShowcaseRenderSignal,
82 getShowcaseRenderSignal,
83 );
6184
62 useEffect(() => {85 useEffect(() => {
63 if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return;86 if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return;
6487
65 const readPairingUrls = () => {88 const readLaunchRequest = () => {
66 const values = getNativeShowcasePairingUrls();89 const values = getNativeShowcasePairingUrls();
67 if (values.length > 0) setPairingUrls(values);90 if (values.length === 0) return;
91 // The palette rides the same launch request as the pairing URLs, so
92 // reading it here settles it without a timeout that could expire while
93 // the request is still on its way.
94 setRequestedTheme(getNativeShowcaseTheme());
95 setThemeRequestSettled(true);
96 setPairingUrls(values);
68 };97 };
69 readPairingUrls();98 readLaunchRequest();
70 const interval = setInterval(readPairingUrls, 250);99 const interval = setInterval(readLaunchRequest, 250);
71 return () => clearInterval(interval);100 return () => clearInterval(interval);
72 }, [pairingUrls.length]);101 }, [pairingUrls.length]);
73102
...@@ -95,13 +124,38 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })...@@ -95,13 +124,38 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
95124
96 const readRequestedScene = () => {125 const readRequestedScene = () => {
97 const value = getNativeShowcaseScene();126 const value = getNativeShowcaseScene();
98 if (value) setRequestedScene(value);127 if (!value || requestedSceneRef.current === value) return;
128 requestedSceneRef.current = value;
129 // A native draw belongs only to the scene request that produced it. In
130 // particular, revisiting review must wait for its newly mounted surface.
131 clearShowcaseRenderSignal();
132 setRequestedScene(value);
99 };133 };
100 readRequestedScene();134 readRequestedScene();
101 const interval = setInterval(readRequestedScene, 250);135 const interval = setInterval(readRequestedScene, 250);
102 return () => clearInterval(interval);136 return () => clearInterval(interval);
103 }, []);137 }, []);
104138
139 // Captures pick a palette for both color schemes so the requested theme is
140 // used whichever system appearance the runner set on the device.
141 const themeApplied =
142 requestedTheme === null
143 ? themeRequestSettled
144 : themeIds.light === requestedTheme && themeIds.dark === requestedTheme;
145
146 useEffect(() => {
147 if (
148 !SHOWCASE_ENABLED ||
149 requestedTheme === null ||
150 themeApplied ||
151 // Writing before stored preferences load would be overwritten by them.
152 !appearancePreferencesReady
153 ) {
154 return;
155 }
156 setThemeIdForBothAppearances(requestedTheme);
157 }, [appearancePreferencesReady, requestedTheme, setThemeIdForBothAppearances, themeApplied]);
158
105 useEffect(() => {159 useEffect(() => {
106 if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return;160 if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return;
107 let cancelled = false;161 let cancelled = false;
...@@ -219,17 +273,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })...@@ -219,17 +273,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
219 !hasFixture ||273 !hasFixture ||
220 // Never report a scene ready while the capture orientation is still274 // Never report a scene ready while the capture orientation is still
221 // being applied — a screenshot taken early has the wrong dimensions.275 // being applied — a screenshot taken early has the wrong dimensions.
222 !orientationSettled276 !orientationSettled ||
277 // Likewise for the palette: an early screenshot shows the default theme.
278 !themeApplied ||
279 !isShowcaseNativeContentReady({ scene, themeId, renderSignal })
223 ) {280 ) {
224 setReadyScene(null);281 setReadyScene(null);
225 return;282 return;
226 }283 }
227 // Review owns its readiness marker because route activation happens before
228 // the VCS request is parsed and the native diff surface is mounted.
229 if (scene === "review") {
230 setReadyScene(null);
231 return;
232 }
233 if (scene === "terminal") Keyboard.dismiss();284 if (scene === "terminal") Keyboard.dismiss();
234285
235 let renderFrame: number | null = null;286 let renderFrame: number | null = null;
...@@ -247,7 +298,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })...@@ -247,7 +298,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
247 if (renderFrame !== null) cancelAnimationFrame(renderFrame);298 if (renderFrame !== null) cancelAnimationFrame(renderFrame);
248 if (readyFrame !== null) cancelAnimationFrame(readyFrame);299 if (readyFrame !== null) cancelAnimationFrame(readyFrame);
249 };300 };
250 }, [hasFixture, orientationSettled, requestedScene, scene]);301 }, [hasFixture, orientationSettled, renderSignal, requestedScene, scene, themeApplied, themeId]);
251302
252 if (!SHOWCASE_ENABLED || readyScene === null) return null;303 if (!SHOWCASE_ENABLED || readyScene === null) return null;
253304
apps/mobile/src/features/showcase/nativeShowcaseScene.ts+17
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { requireOptionalNativeModule } from "expo";1import { requireOptionalNativeModule } from "expo";
22
3import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme";
4
3export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;5export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;
4export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];6export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];
57
...@@ -8,6 +10,7 @@ export type ShowcaseOrientation = "portrait" | "landscape";...@@ -8,6 +10,7 @@ export type ShowcaseOrientation = "portrait" | "landscape";
8interface NativeShowcaseControls {10interface NativeShowcaseControls {
9 readonly getShowcasePairingUrl?: () => string | null;11 readonly getShowcasePairingUrl?: () => string | null;
10 readonly getShowcaseScene?: () => string | null;12 readonly getShowcaseScene?: () => string | null;
13 readonly getShowcaseTheme?: () => string | null;
11 readonly getShowcaseOrientation?: () => string | null;14 readonly getShowcaseOrientation?: () => string | null;
12 readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise<void>;15 readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise<void>;
13 readonly getInterfaceOrientation?: () => Promise<string>;16 readonly getInterfaceOrientation?: () => Promise<string>;
...@@ -56,6 +59,20 @@ export function getNativeShowcaseScene(): ShowcaseScene | null {...@@ -56,6 +59,20 @@ export function getNativeShowcaseScene(): ShowcaseScene | null {
56 }59 }
57}60}
5861
62/**
63 * Returns null when the runner requested no palette, which leaves the stored
64 * theme preference untouched. An unknown id also reads as null rather than
65 * silently falling back, so a capture never claims to show a theme it does not.
66 */
67export function getNativeShowcaseTheme(): MobileThemeId | null {
68 try {
69 const theme = nativeShowcaseControls()?.getShowcaseTheme?.()?.trim();
70 return MOBILE_THEME_IDS.find((candidate) => candidate === theme) ?? null;
71 } catch {
72 return null;
73 }
74}
75
59export function prepareNativeShowcaseCapture(): void {76export function prepareNativeShowcaseCapture(): void {
60 try {77 try {
61 nativeShowcaseControls()?.prepareShowcaseCapture?.();78 nativeShowcaseControls()?.prepareShowcaseCapture?.();
apps/mobile/src/features/showcase/showcaseRenderSignal.test.ts created+52
...@@ -0,0 +1,52 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 clearShowcaseRenderSignal,
5 getShowcaseRenderSignal,
6 isShowcaseNativeContentReady,
7 reportShowcaseSceneRendered,
8 subscribeToShowcaseRenderSignal,
9} from "./showcaseRenderSignal";
10
11afterEach(clearShowcaseRenderSignal);
12
13describe("showcase native content readiness", () => {
14 it("does not gate scenes whose content is rendered by React Native", () => {
15 expect(
16 isShowcaseNativeContentReady({ scene: "environments", themeId: "grove", renderSignal: null }),
17 ).toBe(true);
18 });
19
20 it("waits for the native review surface to draw the active theme", () => {
21 expect(
22 isShowcaseNativeContentReady({ scene: "review", themeId: "grove", renderSignal: null }),
23 ).toBe(false);
24 expect(
25 isShowcaseNativeContentReady({
26 scene: "review",
27 themeId: "grove",
28 renderSignal: { scene: "review", themeId: "ocean" },
29 }),
30 ).toBe(false);
31 expect(
32 isShowcaseNativeContentReady({
33 scene: "review",
34 themeId: "grove",
35 renderSignal: { scene: "review", themeId: "grove" },
36 }),
37 ).toBe(true);
38 });
39
40 it("clears a draw when the runner requests another scene", () => {
41 const listener = vi.fn();
42 const unsubscribe = subscribeToShowcaseRenderSignal(listener);
43
44 reportShowcaseSceneRendered({ scene: "review", themeId: "iris" });
45 expect(getShowcaseRenderSignal()).toEqual({ scene: "review", themeId: "iris" });
46 clearShowcaseRenderSignal();
47 expect(getShowcaseRenderSignal()).toBeNull();
48 expect(listener).toHaveBeenCalledTimes(2);
49
50 unsubscribe();
51 });
52});
apps/mobile/src/features/showcase/showcaseRenderSignal.ts created+39
...@@ -0,0 +1,39 @@
1import type { MobileThemeId } from "../../lib/mobileTheme";
2import type { ShowcaseScene } from "./nativeShowcaseScene";
3
4export type ShowcaseRenderSignal = Readonly<{
5 scene: ShowcaseScene;
6 themeId: MobileThemeId;
7}>;
8
9const listeners = new Set<() => void>();
10let renderSignal: ShowcaseRenderSignal | null = null;
11
12export function getShowcaseRenderSignal(): ShowcaseRenderSignal | null {
13 return renderSignal;
14}
15
16export function subscribeToShowcaseRenderSignal(listener: () => void): () => void {
17 listeners.add(listener);
18 return () => listeners.delete(listener);
19}
20
21export function reportShowcaseSceneRendered(signal: ShowcaseRenderSignal): void {
22 renderSignal = signal;
23 for (const listener of listeners) listener();
24}
25
26export function clearShowcaseRenderSignal(): void {
27 if (renderSignal === null) return;
28 renderSignal = null;
29 for (const listener of listeners) listener();
30}
31
32export function isShowcaseNativeContentReady(input: {
33 readonly scene: ShowcaseScene;
34 readonly themeId: MobileThemeId;
35 readonly renderSignal: ShowcaseRenderSignal | null;
36}): boolean {
37 if (input.scene !== "review") return true;
38 return input.renderSignal?.scene === "review" && input.renderSignal.themeId === input.themeId;
39}
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+7-7
...@@ -7,18 +7,18 @@ import {...@@ -7,18 +7,18 @@ import {
7 type LayoutChangeEvent,7 type LayoutChangeEvent,
8 type NativeSyntheticEvent,8 type NativeSyntheticEvent,
9 type ViewProps,9 type ViewProps,
10 useColorScheme,
11} from "react-native";10} from "react-native";
1211
13import { AppText as Text } from "../../components/AppText";12import { AppText as Text } from "../../components/AppText";
14import { MOBILE_TYPOGRAPHY } from "../../lib/typography";13import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
14import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
15import {15import {
16 getNativeTerminalHardwareKeyRevision,16 getNativeTerminalHardwareKeyRevision,
17 resolveNativeTerminalSurfaceView,17 resolveNativeTerminalSurfaceView,
18} from "./nativeTerminalModule";18} from "./nativeTerminalModule";
19import {19import {
20 buildGhosttyThemeConfig,20 buildGhosttyThemeConfig,
21 getPierreTerminalTheme,21 getMobileTerminalTheme,
22 type TerminalTheme,22 type TerminalTheme,
23} from "./terminalTheme";23} from "./terminalTheme";
24import { terminalDebugLog } from "./terminalDebugLog";24import { terminalDebugLog } from "./terminalDebugLog";
...@@ -60,8 +60,8 @@ function estimateGridSize(input: {...@@ -60,8 +60,8 @@ function estimateGridSize(input: {
60const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {60const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {
61 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;61 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
62 const inputRef = useRef<TextInput>(null);62 const inputRef = useRef<TextInput>(null);
63 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";63 const { themeAppearance, themeId } = useAppearancePreferences();
64 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);64 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
65 const statusLabel = props.isRunning65 const statusLabel = props.isRunning
66 ? "Native terminal unavailable. Using text fallback."66 ? "Native terminal unavailable. Using text fallback."
67 : "Open terminal to start a shell.";67 : "Open terminal to start a shell.";
...@@ -173,8 +173,8 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter...@@ -173,8 +173,8 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
173173
174export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {174export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
175 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;175 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
176 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";176 const { themeAppearance, themeId } = useAppearancePreferences();
177 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);177 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
178 const { onInput, onResize } = props;178 const { onInput, onResize } = props;
179 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();179 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
180 const hasNativeSurface = Boolean(NativeTerminalSurfaceView);180 const hasNativeSurface = Boolean(NativeTerminalSurfaceView);
...@@ -215,7 +215,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf...@@ -215,7 +215,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
215 return (215 return (
216 <View style={props.style}>216 <View style={props.style}>
217 <NativeTerminalSurfaceView217 <NativeTerminalSurfaceView
218 appearanceScheme={appearanceScheme}218 appearanceScheme={themeAppearance}
219 autoFocus={props.autoFocus ?? true}219 autoFocus={props.autoFocus ?? true}
220 backgroundColor={theme.background}220 backgroundColor={theme.background}
221 focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0}221 focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0}
apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx+8-6
...@@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react";...@@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react";
4import { Pressable, View } from "react-native";4import { Pressable, View } from "react-native";
55
6import { AppText as Text } from "../../components/AppText";6import { AppText as Text } from "../../components/AppText";
7import { useThemeColor } from "../../lib/useThemeColor";
7import { terminalEnvironment } from "../../state/terminal";8import { terminalEnvironment } from "../../state/terminal";
8import { useAtomCommand } from "../../state/use-atom-command";9import { useAtomCommand } from "../../state/use-atom-command";
9import { useAttachedTerminalSession } from "../../state/use-terminal-session";10import { useAttachedTerminalSession } from "../../state/use-terminal-session";
...@@ -35,6 +36,7 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(...@@ -35,6 +36,7 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
35 const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");36 const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");
36 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");37 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
37 const nativeTerminalAvailable = hasNativeTerminalSurface();38 const nativeTerminalAvailable = hasNativeTerminalSurface();
39 const iconColor = useThemeColor("--color-icon");
38 const terminalId = DEFAULT_TERMINAL_ID;40 const terminalId = DEFAULT_TERMINAL_ID;
39 const lastGridSizeRef = useRef<TerminalGridSize>({41 const lastGridSizeRef = useRef<TerminalGridSize>({
40 cols: DEFAULT_TERMINAL_COLS,42 cols: DEFAULT_TERMINAL_COLS,
...@@ -214,13 +216,13 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(...@@ -214,13 +216,13 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
214 }216 }
215217
216 return (218 return (
217 <View className="absolute inset-x-3 bottom-28 top-28 overflow-hidden rounded-[8px] border border-white/10 bg-neutral-950 shadow-2xl">219 <View className="absolute inset-x-3 bottom-28 top-28 overflow-hidden rounded-[8px] border border-border bg-screen shadow-2xl">
218 <View className="flex-row items-center justify-between border-b border-white/10 px-3 py-2">220 <View className="flex-row items-center justify-between border-b border-border px-3 py-2">
219 <View className="min-w-0 flex-1">221 <View className="min-w-0 flex-1">
220 <Text className="font-t3-bold text-sm text-neutral-100" numberOfLines={1}>222 <Text className="font-t3-bold text-sm text-foreground" numberOfLines={1}>
221 Terminal223 Terminal
222 </Text>224 </Text>
223 <Text className="text-2xs text-neutral-500" numberOfLines={1}>225 <Text className="text-2xs text-foreground-muted" numberOfLines={1}>
224 {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"}226 {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"}
225 </Text>227 </Text>
226 </View>228 </View>
...@@ -231,10 +233,10 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(...@@ -231,10 +233,10 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
231 </Text>233 </Text>
232 ) : null}234 ) : null}
233 <Pressable235 <Pressable
234 className="h-8 w-8 items-center justify-center rounded-[8px] bg-white/10"236 className="h-8 w-8 items-center justify-center rounded-[8px] bg-subtle"
235 onPress={props.onClose}237 onPress={props.onClose}
236 >238 >
237 <SymbolView name="xmark" size={13} tintColor="#e5e5e5" type="monochrome" />239 <SymbolView name="xmark" size={13} tintColor={iconColor} type="monochrome" />
238 </Pressable>240 </Pressable>
239 </View>241 </View>
240 </View>242 </View>
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+6-4
...@@ -5,7 +5,7 @@ import { SymbolView } from "../../components/AppSymbol";...@@ -5,7 +5,7 @@ import { SymbolView } from "../../components/AppSymbol";
5import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";5import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
6import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";6import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
7import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8import { Platform, Pressable, View, useColorScheme } from "react-native";8import { Platform, Pressable, View } from "react-native";
9import {9import {
10 KeyboardController,10 KeyboardController,
11 KeyboardEvents,11 KeyboardEvents,
...@@ -44,7 +44,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail";...@@ -44,7 +44,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail";
44import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";44import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
45import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";45import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
46import { TerminalSurface } from "./NativeTerminalSurface";46import { TerminalSurface } from "./NativeTerminalSurface";
47import { getPierreTerminalTheme } from "./terminalTheme";47import { getMobileTerminalTheme } from "./terminalTheme";
48import { terminalDebugLog } from "./terminalDebugLog";48import { terminalDebugLog } from "./terminalDebugLog";
49import {49import {
50 getTerminalBufferReplayKey,50 getTerminalBufferReplayKey,
...@@ -166,7 +166,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -166,7 +166,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
166 const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");166 const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");
167 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");167 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
168 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");168 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
169 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
170 const { state: workspaceState } = useWorkspaceState();169 const { state: workspaceState } = useWorkspaceState();
171 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();170 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
172 const params = props.route.params;171 const params = props.route.params;
...@@ -186,6 +185,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -186,6 +185,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
186 const {185 const {
187 isReady: hasResolvedFontPreference,186 isReady: hasResolvedFontPreference,
188 appearance,187 appearance,
188 themeAppearance: appearanceScheme,
189 themeId,
189 setTerminalFontSize,190 setTerminalFontSize,
190 } = useAppearancePreferences();191 } = useAppearancePreferences();
191 const fontSize = appearance.terminalFontSize;192 const fontSize = appearance.terminalFontSize;
...@@ -466,7 +467,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -466,7 +467,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
466 [selectedEnvironmentConnection?.environmentLabel],467 [selectedEnvironmentConnection?.environmentLabel],
467 );468 );
468469
469 const terminalTheme = getPierreTerminalTheme(appearanceScheme);470 const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme);
470 const usesNativeHeaderGlass = Platform.OS === "ios";471 const usesNativeHeaderGlass = Platform.OS === "ios";
471 const pendingModifier =472 const pendingModifier =
472 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;473 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
...@@ -1228,6 +1229,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1228,6 +1229,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1228 onResize={handleResize}1229 onResize={handleResize}
1229 style={{ flex: 1 }}1230 style={{ flex: 1 }}
1230 terminalKey={terminalKey}1231 terminalKey={terminalKey}
1232 theme={terminalTheme}
1231 />1233 />
1232 </View>1234 </View>
12331235
apps/mobile/src/features/terminal/terminalTheme.test.ts+35-1
...@@ -1,6 +1,13 @@...@@ -1,6 +1,13 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/themePalettes";
23
3import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme";4import { themeColorToNativeColor } from "../../lib/mobileTheme";
5
6import {
7 buildGhosttyThemeConfig,
8 getMobileTerminalTheme,
9 getPierreTerminalTheme,
10} from "./terminalTheme";
411
5describe("getPierreTerminalTheme", () => {12describe("getPierreTerminalTheme", () => {
6 it("returns the Pierre light terminal palette", () => {13 it("returns the Pierre light terminal palette", () => {
...@@ -22,6 +29,33 @@ describe("getPierreTerminalTheme", () => {...@@ -22,6 +29,33 @@ describe("getPierreTerminalTheme", () => {
22 });29 });
23});30});
2431
32describe("getMobileTerminalTheme", () => {
33 it("preserves the Pierre terminal for the default theme", () => {
34 for (const scheme of ["light", "dark"] as const) {
35 expect(getMobileTerminalTheme("t3-code", scheme)).toEqual(getPierreTerminalTheme(scheme));
36 }
37 });
38
39 it("applies the selected palette without replacing ANSI status colors", () => {
40 const standard = getMobileTerminalTheme("t3-code", "dark");
41 const ocean = getMobileTerminalTheme("ocean", "dark");
42
43 expect(ocean.background).not.toBe(standard.background);
44 expect(ocean.cursorForeground).not.toBe(standard.cursorForeground);
45 expect(ocean.palette).toEqual(standard.palette);
46 });
47
48 it("uses the canonical desktop terminal roles for built-in themes", () => {
49 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === "ocean")!;
50 const colors = getThemeColorsForAppearance(theme, "dark")!;
51 const terminal = getMobileTerminalTheme("ocean", "dark");
52
53 expect(terminal.background).toBe(themeColorToNativeColor(colors.terminalBackground));
54 expect(terminal.foreground).toBe(themeColorToNativeColor(colors.terminalForeground));
55 expect(terminal.cursorForeground).toBe(themeColorToNativeColor(colors.terminalCursor));
56 });
57});
58
25describe("buildGhosttyThemeConfig", () => {59describe("buildGhosttyThemeConfig", () => {
26 it("serializes theme colors into a ghostty config file", () => {60 it("serializes theme colors into a ghostty config file", () => {
27 const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark"));61 const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark"));
apps/mobile/src/features/terminal/terminalTheme.ts+30
...@@ -1,3 +1,11 @@...@@ -1,3 +1,11 @@
1import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/themePalettes";
2
3import {
4 getMobileThemeVariables,
5 themeColorToNativeColor,
6 type MobileThemeId,
7} from "../../lib/mobileTheme";
8
1export type TerminalAppearanceScheme = "light" | "dark";9export type TerminalAppearanceScheme = "light" | "dark";
210
3export interface TerminalTheme {11export interface TerminalTheme {
...@@ -70,6 +78,28 @@ export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): Termin...@@ -70,6 +78,28 @@ export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): Termin
70 return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME;78 return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME;
71}79}
7280
81export function getMobileTerminalTheme(
82 themeId: MobileThemeId,
83 scheme: TerminalAppearanceScheme,
84): TerminalTheme {
85 const base = getPierreTerminalTheme(scheme);
86 if (themeId === "t3-code") return base;
87
88 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
89 const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors;
90 const colors = getMobileThemeVariables(themeId, scheme);
91 const background = themeColorToNativeColor(palette.terminalBackground);
92 return {
93 ...base,
94 background,
95 foreground: themeColorToNativeColor(palette.terminalForeground),
96 mutedForeground: colors["--color-foreground-muted"],
97 border: colors["--color-border"],
98 cursorForeground: themeColorToNativeColor(palette.terminalCursor),
99 cursorBackground: background,
100 };
101}
102
73export function buildGhosttyThemeConfig(theme: TerminalTheme): string {103export function buildGhosttyThemeConfig(theme: TerminalTheme): string {
74 const lines = [104 const lines = [
75 `background = ${theme.background}`,105 `background = ${theme.background}`,
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+10-16
...@@ -2,11 +2,12 @@ import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";...@@ -2,11 +2,12 @@ import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
2import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";2import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
3import { SymbolView } from "../../components/AppSymbol";3import { SymbolView } from "../../components/AppSymbol";
4import { memo } from "react";4import { memo } from "react";
5import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native";5import { Pressable, ScrollView, View, type ViewStyle } from "react-native";
66
7import { AppText as Text } from "../../components/AppText";7import { AppText as Text } from "../../components/AppText";
8import { GlassSurface } from "../../components/GlassSurface";8import { GlassSurface } from "../../components/GlassSurface";
9import { PierreEntryIcon } from "../../components/PierreEntryIcon";9import { PierreEntryIcon } from "../../components/PierreEntryIcon";
10import { useThemeColor } from "../../lib/useThemeColor";
10export type ComposerCommandItem =11export type ComposerCommandItem =
11 | {12 | {
12 readonly id: string;13 readonly id: string;
...@@ -45,11 +46,8 @@ interface ComposerCommandPopoverProps {...@@ -45,11 +46,8 @@ interface ComposerCommandPopoverProps {
45 readonly onSelect: (item: ComposerCommandItem) => void;46 readonly onSelect: (item: ComposerCommandItem) => void;
46}47}
4748
48function PopoverSurface(props: {49function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) {
49 readonly children: React.ReactNode;50 const tintColor = useThemeColor("--color-glass-surface");
50 readonly isDarkMode: boolean;
51 readonly style?: ViewStyle;
52}) {
53 const baseStyle: ViewStyle = {51 const baseStyle: ViewStyle = {
54 borderRadius: 16,52 borderRadius: 16,
55 overflow: "hidden",53 overflow: "hidden",
...@@ -57,11 +55,7 @@ function PopoverSurface(props: {...@@ -57,11 +55,7 @@ function PopoverSurface(props: {
57 };55 };
5856
59 return (57 return (
60 <GlassSurface58 <GlassSurface glassEffectStyle="clear" tintColor={tintColor} style={baseStyle}>
61 glassEffectStyle="clear"
62 tintColor={props.isDarkMode ? "rgba(30,30,32,0.95)" : "rgba(255,255,255,0.92)"}
63 style={baseStyle}
64 >
65 {props.children}59 {props.children}
66 </GlassSurface>60 </GlassSurface>
67 );61 );
...@@ -114,7 +108,8 @@ const CommandRow = memo(function CommandRow(props: {...@@ -114,7 +108,8 @@ const CommandRow = memo(function CommandRow(props: {
114 readonly isLast: boolean;108 readonly isLast: boolean;
115}) {109}) {
116 const iconName = itemIcon(props.item);110 const iconName = itemIcon(props.item);
117 const iconColor = "#a1a1aa";111 const iconColor = useThemeColor("--color-icon-subtle");
112 const borderColor = useThemeColor("--color-border");
118113
119 return (114 return (
120 <Pressable115 <Pressable
...@@ -127,7 +122,7 @@ const CommandRow = memo(function CommandRow(props: {...@@ -127,7 +122,7 @@ const CommandRow = memo(function CommandRow(props: {
127 gap: 10,122 gap: 10,
128 opacity: pressed ? 0.6 : 1,123 opacity: pressed ? 0.6 : 1,
129 borderBottomWidth: props.isLast ? 0 : 0.5,124 borderBottomWidth: props.isLast ? 0 : 0.5,
130 borderBottomColor: "rgba(255,255,255,0.1)",125 borderBottomColor: borderColor,
131 })}126 })}
132 >127 >
133 {props.item.type === "path" ? (128 {props.item.type === "path" ? (
...@@ -139,7 +134,7 @@ const CommandRow = memo(function CommandRow(props: {...@@ -139,7 +134,7 @@ const CommandRow = memo(function CommandRow(props: {
139 {props.item.label}134 {props.item.label}
140 </Text>135 </Text>
141 {props.item.description ? (136 {props.item.description ? (
142 <Text className="min-w-0 flex-1 text-xs text-zinc-400" numberOfLines={1}>137 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>
143 {props.item.description}138 {props.item.description}
144 </Text>139 </Text>
145 ) : null}140 ) : null}
...@@ -150,11 +145,10 @@ const CommandRow = memo(function CommandRow(props: {...@@ -150,11 +145,10 @@ const CommandRow = memo(function CommandRow(props: {
150export const ComposerCommandPopover = memo(function ComposerCommandPopover(145export const ComposerCommandPopover = memo(function ComposerCommandPopover(
151 props: ComposerCommandPopoverProps,146 props: ComposerCommandPopoverProps,
152) {147) {
153 const isDarkMode = useColorScheme() === "dark";
154 const label = groupLabel(props.triggerKind);148 const label = groupLabel(props.triggerKind);
155149
156 return (150 return (
157 <PopoverSurface isDarkMode={isDarkMode}>151 <PopoverSurface>
158 {label ? (152 {label ? (
159 <View className="px-3.5 pt-2.5 pb-1">153 <View className="px-3.5 pt-2.5 pb-1">
160 <Text className="text-3xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">154 <Text className="text-3xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
apps/mobile/src/features/threads/GitActionProgressOverlay.tsx+4-2
...@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";...@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
2import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";2import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
3import { SymbolView } from "../../components/AppSymbol";3import { SymbolView } from "../../components/AppSymbol";
4import { useCallback, useEffect, useRef } from "react";4import { useCallback, useEffect, useRef } from "react";
5import { ActivityIndicator, Pressable, StyleSheet, useColorScheme, View } from "react-native";5import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native";
6import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";6import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
7import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
88
...@@ -11,6 +11,7 @@ import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics";...@@ -11,6 +11,7 @@ import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics";
11import { tryOpenExternalUrl } from "../../lib/openExternalUrl";11import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
12import { useThemeColor } from "../../lib/useThemeColor";12import { useThemeColor } from "../../lib/useThemeColor";
13import type { GitActionProgress } from "../../state/use-vcs-action-state";13import type { GitActionProgress } from "../../state/use-vcs-action-state";
14import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1415
15const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220);16const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220);
16const OVERLAY_TOP_GAP = 8;17const OVERLAY_TOP_GAP = 8;
...@@ -69,7 +70,8 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) {...@@ -69,7 +70,8 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) {
69 const iconColor = useThemeColor("--color-icon");70 const iconColor = useThemeColor("--color-icon");
70 const glassBorder = useThemeColor("--color-header-border");71 const glassBorder = useThemeColor("--color-header-border");
71 const glassTint = useThemeColor("--color-glass-tint");72 const glassTint = useThemeColor("--color-glass-tint");
72 const isDarkMode = useColorScheme() === "dark";73 const { themeAppearance } = useAppearancePreferences();
74 const isDarkMode = themeAppearance === "dark";
73 const content = (75 const content = (
74 <>76 <>
75 <OverlayIcon phase={progress.phase} iconColor={iconColor} />77 <OverlayIcon phase={progress.phase} iconColor={iconColor} />
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+2-2
...@@ -13,7 +13,6 @@ import {...@@ -13,7 +13,6 @@ import {
13 Platform,13 Platform,
14 Pressable,14 Pressable,
15 ScrollView,15 ScrollView,
16 Switch,
17 TextInput,16 TextInput,
18 View,17 View,
19} from "react-native";18} from "react-native";
...@@ -22,6 +21,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -22,6 +21,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
22import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";21import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
23import { SymbolView } from "../../components/AppSymbol";22import { SymbolView } from "../../components/AppSymbol";
24import { AppText as Text } from "../../components/AppText";23import { AppText as Text } from "../../components/AppText";
24import { ThemedSwitch } from "../../components/ThemedSwitch";
25import { cn } from "../../lib/cn";25import { cn } from "../../lib/cn";
26import { useFontFamily } from "../../lib/useFontFamily";26import { useFontFamily } from "../../lib/useFontFamily";
27import { useThemeColor } from "../../lib/useThemeColor";27import { useThemeColor } from "../../lib/useThemeColor";
...@@ -97,7 +97,7 @@ function ToggleRow(props: {...@@ -97,7 +97,7 @@ function ToggleRow(props: {
97 <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}>97 <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}>
98 {props.title}98 {props.title}
99 </Text>99 </Text>
100 <Switch100 <ThemedSwitch
101 accessibilityLabel={props.title}101 accessibilityLabel={props.title}
102 onValueChange={props.onValueChange}102 onValueChange={props.onValueChange}
103 value={props.value}103 value={props.value}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+7-4
...@@ -6,7 +6,7 @@ import {...@@ -6,7 +6,7 @@ import {
6 usePreventRemove,6 usePreventRemove,
7} from "@react-navigation/native";7} from "@react-navigation/native";
8import { useCallback, useEffect, useRef, useState } from "react";8import { useCallback, useEffect, useRef, useState } from "react";
9import { Alert, Platform, Pressable, ScrollView, View, useColorScheme } from "react-native";9import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
10import {10import {
11 KeyboardController,11 KeyboardController,
12 KeyboardStickyView,12 KeyboardStickyView,
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14} from "react-native-keyboard-controller";14} from "react-native-keyboard-controller";
15import { useSafeAreaInsets } from "react-native-safe-area-context";15import { useSafeAreaInsets } from "react-native-safe-area-context";
16import { useThemeColor } from "../../lib/useThemeColor";16import { useThemeColor } from "../../lib/useThemeColor";
17import { themeColorWithAlpha } from "../../lib/mobileTheme";
17import { useFontFamily } from "../../lib/useFontFamily";18import { useFontFamily } from "../../lib/useFontFamily";
1819
19import {20import {
...@@ -42,6 +43,7 @@ import {...@@ -42,6 +43,7 @@ import {
42import { makeTurnCommandMetadata } from "../../lib/commandMetadata";43import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
43import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";44import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
44import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";45import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
46import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
45import {47import {
46 clearComposerDraftContent,48 clearComposerDraftContent,
47 getComposerDraftSnapshot,49 getComposerDraftSnapshot,
...@@ -107,7 +109,7 @@ export function NewTaskDraftScreen(props: {...@@ -107,7 +109,7 @@ export function NewTaskDraftScreen(props: {
107 reserveShare,109 reserveShare,
108 } = useIncomingShare();110 } = useIncomingShare();
109 const insets = useSafeAreaInsets();111 const insets = useSafeAreaInsets();
110 const colorScheme = useColorScheme();112 const { themeAppearance: colorScheme } = useAppearancePreferences();
111 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);113 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
112 const controlsBottomPadding = Math.max(insets.bottom, 10);114 const controlsBottomPadding = Math.max(insets.bottom, 10);
113 const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8);115 const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8);
...@@ -290,11 +292,12 @@ export function NewTaskDraftScreen(props: {...@@ -290,11 +292,12 @@ export function NewTaskDraftScreen(props: {
290 }, [props.pendingTaskId, cancelEditingPendingTask]);292 }, [props.pendingTaskId, cancelEditingPendingTask]);
291293
292 const foregroundColor = useThemeColor("--color-foreground");294 const foregroundColor = useThemeColor("--color-foreground");
295 const sheetColor = String(useThemeColor("--color-sheet"));
293 const projectUnderlineColor = useThemeColor("--color-foreground-muted");296 const projectUnderlineColor = useThemeColor("--color-foreground-muted");
294 const regularFontFamily = useFontFamily("regular");297 const regularFontFamily = useFontFamily("regular");
295 const bodyText = useScaledTextRole("body");298 const bodyText = useScaledTextRole("body");
296 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";299 const sheetFadeOpaque = sheetColor;
297 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";300 const sheetFadeTransparent = themeColorWithAlpha(sheetColor, 0);
298301
299 // A new navigation to this mounted screen delivers a fresh initialProjectRef302 // A new navigation to this mounted screen delivers a fresh initialProjectRef
300 // reference — treat it as a new request and let it apply again.303 // reference — treat it as a new request and let it apply again.
apps/mobile/src/features/threads/ThreadComposer.tsx+19-12
...@@ -22,7 +22,6 @@ import {...@@ -22,7 +22,6 @@ import {
22 Platform,22 Platform,
23 Pressable,23 Pressable,
24 StyleSheet,24 StyleSheet,
25 useColorScheme,
26 View,25 View,
27 type ViewStyle,26 type ViewStyle,
28} from "react-native";27} from "react-native";
...@@ -35,6 +34,7 @@ import Animated, {...@@ -35,6 +34,7 @@ import Animated, {
35 LinearTransition,34 LinearTransition,
36} from "react-native-reanimated";35} from "react-native-reanimated";
37import { useThemeColor } from "../../lib/useThemeColor";36import { useThemeColor } from "../../lib/useThemeColor";
37import { themeColorWithAlpha } from "../../lib/mobileTheme";
38import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";38import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
39import { scopedThreadKey } from "../../lib/scopedEntities";39import { scopedThreadKey } from "../../lib/scopedEntities";
4040
...@@ -57,6 +57,7 @@ import { ProviderIcon } from "../../components/ProviderIcon";...@@ -57,6 +57,7 @@ import { ProviderIcon } from "../../components/ProviderIcon";
57import type { DraftComposerImageAttachment } from "../../lib/composerImages";57import type { DraftComposerImageAttachment } from "../../lib/composerImages";
58import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";58import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
59import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";59import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
60import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
60import type { RemoteClientConnectionState } from "../../lib/connection";61import type { RemoteClientConnectionState } from "../../lib/connection";
61import {62import {
62 insertRankedSearchResult,63 insertRankedSearchResult,
...@@ -144,11 +145,14 @@ export function ComposerSurface(props: {...@@ -144,11 +145,14 @@ export function ComposerSurface(props: {
144 /** Existing thread composers morph between pill and card layouts. */145 /** Existing thread composers morph between pill and card layouts. */
145 readonly animateLayout?: boolean;146 readonly animateLayout?: boolean;
146}) {147}) {
148 const cardColor = useThemeColor("--color-card-translucent");
149 const borderColor = useThemeColor("--color-border");
150 const shadowColor = useThemeColor("--color-primary-shadow");
147 // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself151 // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself
148 // (needed to clip content to the pill shape) would clip the shadow on iOS.152 // (needed to clip content to the pill shape) would clip the shadow on iOS.
149 const shadowStyle: ViewStyle = {153 const shadowStyle: ViewStyle = {
150 borderRadius: props.style.borderRadius,154 borderRadius: props.style.borderRadius,
151 shadowColor: "#000000",155 shadowColor,
152 shadowOpacity: props.isDarkMode ? 0.35 : 0.12,156 shadowOpacity: props.isDarkMode ? 0.35 : 0.12,
153 shadowRadius: 14,157 shadowRadius: 14,
154 shadowOffset: { width: 0, height: 6 },158 shadowOffset: { width: 0, height: 6 },
...@@ -163,9 +167,9 @@ export function ComposerSurface(props: {...@@ -163,9 +167,9 @@ export function ComposerSurface(props: {
163 <GlassSurface167 <GlassSurface
164 chrome="none"168 chrome="none"
165 fallbackStyle={{169 fallbackStyle={{
166 backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",170 backgroundColor: cardColor,
167 borderWidth: 1,171 borderWidth: 1,
168 borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",172 borderColor,
169 }}173 }}
170 glassEffectStyle="regular"174 glassEffectStyle="regular"
171 // The composer is a passive material containing interactive controls.175 // The composer is a passive material containing interactive controls.
...@@ -235,6 +239,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(...@@ -235,6 +239,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
235 readonly status: ComposerStatusPillState;239 readonly status: ComposerStatusPillState;
236}) {240}) {
237 const isReconnecting = props.status.kind !== "unavailable";241 const isReconnecting = props.status.kind !== "unavailable";
242 const indicatorColor = useThemeColor("--color-icon-muted");
238243
239 return (244 return (
240 <Animated.View245 <Animated.View
...@@ -246,10 +251,10 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(...@@ -246,10 +251,10 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
246 <Pressable251 <Pressable
247 accessibilityRole="button"252 accessibilityRole="button"
248 onPress={props.onPress}253 onPress={props.onPress}
249 className="max-w-full flex-row items-center gap-2 rounded-full bg-white/90 px-3 py-2 shadow-sm active:opacity-70 dark:bg-neutral-900/90"254 className="max-w-full flex-row items-center gap-2 rounded-full bg-card px-3 py-2 shadow-sm active:opacity-70"
250 >255 >
251 {isReconnecting ? (256 {isReconnecting ? (
252 <ActivityIndicator size="small" color="#8e8e93" />257 <ActivityIndicator size="small" color={indicatorColor} />
253 ) : (258 ) : (
254 <View className="h-2 w-2 rounded-full bg-red-500" />259 <View className="h-2 w-2 rounded-full bg-red-500" />
255 )}260 )}
...@@ -266,7 +271,8 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(...@@ -266,7 +271,8 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
266271
267export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {272export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
268 const navigation = useNavigation();273 const navigation = useNavigation();
269 const isDarkMode = useColorScheme() === "dark";274 const { themeAppearance } = useAppearancePreferences();
275 const isDarkMode = themeAppearance === "dark";
270 const foregroundColor = useThemeColor("--color-foreground");276 const foregroundColor = useThemeColor("--color-foreground");
271 const bodyText = useScaledTextRole("body");277 const bodyText = useScaledTextRole("body");
272 const fallbackInputRef = useRef<ComposerEditorHandle>(null);278 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
...@@ -335,8 +341,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -335,8 +341,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
335 environmentLabel: props.environmentLabel,341 environmentLabel: props.environmentLabel,
336 threadSyncPhase: props.threadSyncPhase,342 threadSyncPhase: props.threadSyncPhase,
337 });343 });
338 const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";344 const toolbarSurface = String(useThemeColor("--color-card"));
339 const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)";345 const backdropSurface = String(useThemeColor("--color-screen"));
346 const toolbarFadeOpaque = themeColorWithAlpha(toolbarSurface, 0.95);
347 const toolbarFadeTransparent = themeColorWithAlpha(toolbarSurface, 0);
348 const backdropGradient = `linear-gradient(to bottom, ${themeColorWithAlpha(backdropSurface, 0)} 0%, ${themeColorWithAlpha(backdropSurface, 0.6)} 55%, ${themeColorWithAlpha(backdropSurface, 0.9)} 100%)`;
340 const selectedProviderStatus = useMemo(() => {349 const selectedProviderStatus = useMemo(() => {
341 if (!props.serverConfig) return null;350 if (!props.serverConfig) return null;
342 return (351 return (
...@@ -704,9 +713,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -704,9 +713,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
704 style={[713 style={[
705 StyleSheet.absoluteFill,714 StyleSheet.absoluteFill,
706 {715 {
707 experimental_backgroundImage: isDarkMode716 experimental_backgroundImage: backdropGradient,
708 ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 55%, rgba(0,0,0,0.9) 100%)"
709 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)",
710 },717 },
711 ]}718 ]}
712 />719 />
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+3-2
...@@ -32,7 +32,6 @@ import {...@@ -32,7 +32,6 @@ import {
32 AppState,32 AppState,
33 Keyboard,33 Keyboard,
34 Platform,34 Platform,
35 useColorScheme,
36 useWindowDimensions,35 useWindowDimensions,
37 View,36 View,
38 type GestureResponderEvent,37 type GestureResponderEvent,
...@@ -53,6 +52,7 @@ import Animated, {...@@ -53,6 +52,7 @@ import Animated, {
53import { useSafeAreaInsets } from "react-native-safe-area-context";52import { useSafeAreaInsets } from "react-native-safe-area-context";
5453
55import { ControlPill } from "../../components/ControlPill";54import { ControlPill } from "../../components/ControlPill";
55import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
56import type { ComposerEditorHandle } from "../../components/ComposerEditor";56import type { ComposerEditorHandle } from "../../components/ComposerEditor";
57import type { StatusTone } from "../../components/StatusPill";57import type { StatusTone } from "../../components/StatusPill";
58import type { DraftComposerImageAttachment } from "../../lib/composerImages";58import type { DraftComposerImageAttachment } from "../../lib/composerImages";
...@@ -539,7 +539,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -539,7 +539,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
539 }, [freeze, scrollMessageToEnd]);539 }, [freeze, scrollMessageToEnd]);
540540
541 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;541 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
542 const isDarkMode = useColorScheme() === "dark";542 const { themeAppearance } = useAppearancePreferences();
543 const isDarkMode = themeAppearance === "dark";
543544
544 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {545 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
545 feedTouchStartRef.current = {546 feedTouchStartRef.current = {
apps/mobile/src/features/threads/ThreadFeed.tsx+45-73
...@@ -37,7 +37,6 @@ import {...@@ -37,7 +37,6 @@ import {
37 StyleSheet,37 StyleSheet,
38 Text as NativeText,38 Text as NativeText,
39 type ColorValue,39 type ColorValue,
40 useColorScheme,
41 useWindowDimensions,40 useWindowDimensions,
42 View,41 View,
43} from "react-native";42} from "react-native";
...@@ -194,43 +193,6 @@ function MessageAttachmentImage(props: {...@@ -194,43 +193,6 @@ function MessageAttachmentImage(props: {
194 );193 );
195}194}
196195
197const MARKDOWN_COLORS = {
198 light: {
199 body: "#111111",
200 strong: "#000000",
201 link: "#2563eb",
202 blockquoteBorder: "rgba(0, 0, 0, 0.08)",
203 blockquoteBackground: "rgba(0, 0, 0, 0.02)",
204 codeBackground: "rgba(0, 0, 0, 0.04)",
205 codeText: "#262626",
206 inlineCodeText: "#5f6368",
207 horizontalRule: "rgba(0, 0, 0, 0.08)",
208 userBody: "#ffffff",
209 userCodeBackground: "rgba(255, 255, 255, 0.22)",
210 userCodeText: "#ffffff",
211 userInlineCodeText: "rgba(255, 255, 255, 0.82)",
212 userFenceBackground: "rgba(0, 0, 0, 0.16)",
213 userFenceText: "#ffffff",
214 },
215 dark: {
216 body: "#e5e5e5",
217 strong: "#f5f5f5",
218 link: "#60a5fa",
219 blockquoteBorder: "rgba(255, 255, 255, 0.1)",
220 blockquoteBackground: "rgba(255, 255, 255, 0.03)",
221 codeBackground: "rgba(255, 255, 255, 0.06)",
222 codeText: "#e5e5e5",
223 inlineCodeText: "#b8bcc2",
224 horizontalRule: "rgba(255, 255, 255, 0.08)",
225 userBody: "#ffffff",
226 userCodeBackground: "rgba(255, 255, 255, 0.18)",
227 userCodeText: "#ffffff",
228 userInlineCodeText: "rgba(255, 255, 255, 0.82)",
229 userFenceBackground: "rgba(0, 0, 0, 0.28)",
230 userFenceText: "#ffffff",
231 },
232} as const;
233
234const MARKDOWN_MONO_FONT = Platform.select({196const MARKDOWN_MONO_FONT = Platform.select({
235 ios: "ui-monospace",197 ios: "ui-monospace",
236 android: "monospace",198 android: "monospace",
...@@ -426,14 +388,12 @@ function MarkdownCodeBlock(props: {...@@ -426,14 +388,12 @@ function MarkdownCodeBlock(props: {
426}388}
427389
428function useReviewCommentColors(): ReviewCommentColors {390function useReviewCommentColors(): ReviewCommentColors {
429 const colorScheme = useColorScheme();391 const background = useThemeColor("--color-card");
430 const isDark = colorScheme === "dark";392 const border = useThemeColor("--color-border");
431 const background = isDark ? "#151515" : "#ffffff";393 const mutedBackground = useThemeColor("--color-subtle");
432 const border = isDark ? "#2a2a2a" : "#d7d7d7";394 const text = useThemeColor("--color-foreground");
433 const mutedBackground = isDark ? "#242424" : "#f2f2f2";395 const mutedText = useThemeColor("--color-foreground-muted");
434 const text = isDark ? "#f3f3f3" : "#111111";396 const codeBackground = useThemeColor("--color-md-code-bg");
435 const mutedText = isDark ? "#8f8f8f" : "#666666";
436 const codeBackground = isDark ? "#0f0f0f" : "#ffffff";
437397
438 return useMemo(398 return useMemo(
439 () => ({399 () => ({
...@@ -449,8 +409,7 @@ function useReviewCommentColors(): ReviewCommentColors {...@@ -449,8 +409,7 @@ function useReviewCommentColors(): ReviewCommentColors {
449}409}
450410
451function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {411function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {
452 const colorScheme = useColorScheme();412 const { appearance, themeAppearance } = useAppearancePreferences();
453 const { appearance } = useAppearancePreferences();
454 const markdownFontSizes = useMemo(413 const markdownFontSizes = useMemo(
455 () => resolveMarkdownFontSizes(appearance.baseFontSize),414 () => resolveMarkdownFontSizes(appearance.baseFontSize),
456 [appearance.baseFontSize],415 [appearance.baseFontSize],
...@@ -459,31 +418,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -459,31 +418,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
459 () => resolveNativeMarkdownTypography(appearance.baseFontSize),418 () => resolveNativeMarkdownTypography(appearance.baseFontSize),
460 [appearance.baseFontSize],419 [appearance.baseFontSize],
461 );420 );
462 const themeMode = colorScheme === "dark" ? "dark" : "light";421 const themeMode = themeAppearance;
463 const colors = MARKDOWN_COLORS[themeMode];422 const markdownBodyColor = String(useThemeColor("--color-md-body"));
423 const markdownStrongColor = String(useThemeColor("--color-md-strong"));
424 const markdownLinkColor = String(useThemeColor("--color-md-link"));
425 const markdownBlockquoteBg = String(useThemeColor("--color-md-blockquote-bg"));
426 const markdownBlockquoteBorder = String(useThemeColor("--color-md-blockquote-border"));
427 const markdownCodeBg = String(useThemeColor("--color-md-code-bg"));
428 const markdownCodeText = String(useThemeColor("--color-md-code-text"));
429 const markdownInlineCodeText = String(useThemeColor("--color-foreground-secondary"));
430 const markdownHrColor = String(useThemeColor("--color-md-hr"));
431 const markdownUserBodyColor = String(useThemeColor("--color-user-bubble-foreground"));
432 const markdownUserCodeBg = String(useThemeColor("--color-md-user-code-bg"));
433 const markdownUserCodeText = String(useThemeColor("--color-md-user-code-text"));
434 const markdownUserInlineCodeText = String(useThemeColor("--color-user-bubble-foreground-muted"));
435 const markdownUserFenceBg = String(useThemeColor("--color-md-user-fence-bg"));
436 const markdownUserFenceText = String(useThemeColor("--color-md-user-fence-text"));
464 const iconSubtleColor = String(useThemeColor("--color-icon-subtle"));437 const iconSubtleColor = String(useThemeColor("--color-icon-subtle"));
465 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));438 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
439 const userBubbleSkillForeground = String(useThemeColor("--color-user-bubble-skill-foreground"));
466 const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted"));440 const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted"));
467 const regularFontFamily = useFontFamily("regular");441 const regularFontFamily = useFontFamily("regular");
468 const boldFontFamily = useFontFamily("bold");442 const boldFontFamily = useFontFamily("bold");
469443
470 return useMemo(() => {444 return useMemo(() => {
471 const markdownBodyColor = colors.body;
472 const markdownStrongColor = colors.strong;
473 const markdownLinkColor = colors.link;
474 const markdownBlockquoteBg = colors.blockquoteBackground;
475 const markdownBlockquoteBorder = colors.blockquoteBorder;
476 const markdownCodeBg = colors.codeBackground;
477 const markdownCodeText = colors.codeText;
478 const markdownInlineCodeText = colors.inlineCodeText;
479 const markdownHrColor = colors.horizontalRule;
480 const markdownUserBodyColor = colors.userBody;
481 const markdownUserCodeBg = colors.userCodeBackground;
482 const markdownUserCodeText = colors.userCodeText;
483 const markdownUserInlineCodeText = colors.userInlineCodeText;
484 const markdownUserFenceBg = colors.userFenceBackground;
485 const markdownUserFenceText = colors.userFenceText;
486
487 const baseTheme: PartialMarkdownTheme = {445 const baseTheme: PartialMarkdownTheme = {
488 colors: {446 colors: {
489 text: markdownBodyColor,447 text: markdownBodyColor,
...@@ -759,8 +717,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -759,8 +717,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
759 codeColor: markdownUserCodeText,717 codeColor: markdownUserCodeText,
760 codeBackgroundColor: markdownUserCodeBg,718 codeBackgroundColor: markdownUserCodeBg,
761 codeBlockBackgroundColor: markdownUserFenceBg,719 codeBlockBackgroundColor: markdownUserFenceBg,
762 fileTextColor: "#ffffff",720 fileTextColor: markdownUserBodyColor,
763 skillTextColor: "#f0abfc",721 skillTextColor: userBubbleSkillForeground,
764 quoteMarkerColor: markdownUserBodyColor,722 quoteMarkerColor: markdownUserBodyColor,
765 dividerColor: markdownUserBodyColor,723 dividerColor: markdownUserBodyColor,
766 fontSize: nativeMarkdownTypography.fontSize,724 fontSize: nativeMarkdownTypography.fontSize,
...@@ -807,15 +765,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -807,15 +765,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
807 };765 };
808 }, [766 }, [
809 boldFontFamily,767 boldFontFamily,
810 colors,
811 iconSubtleColor,768 iconSubtleColor,
812 inlineSkillForeground,769 inlineSkillForeground,
770 markdownBlockquoteBg,
771 markdownBlockquoteBorder,
772 markdownBodyColor,
773 markdownCodeBg,
774 markdownCodeText,
813 markdownFontSizes,775 markdownFontSizes,
776 markdownHrColor,
777 markdownInlineCodeText,
778 markdownLinkColor,
779 markdownStrongColor,
780 markdownUserBodyColor,
781 markdownUserCodeBg,
782 markdownUserCodeText,
783 markdownUserFenceBg,
784 markdownUserFenceText,
785 markdownUserInlineCodeText,
814 nativeMarkdownTypography,786 nativeMarkdownTypography,
815 onLinkPress,787 onLinkPress,
816 regularFontFamily,788 regularFontFamily,
817 themeMode,789 themeMode,
818 userBubbleForegroundMuted,790 userBubbleForegroundMuted,
791 userBubbleSkillForeground,
819 ]);792 ]);
820}793}
821794
...@@ -1141,8 +1114,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {...@@ -1141,8 +1114,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
1141 readonly colors: ReviewCommentColors;1114 readonly colors: ReviewCommentColors;
1142}) {1115}) {
1143 const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface();1116 const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface();
1144 const colorScheme = useColorScheme();1117 const { themeAppearance: appearanceScheme, themeId } = useAppearancePreferences();
1145 const appearanceScheme = colorScheme === "light" ? "light" : "dark";
1146 const NativeReviewDiffView = resolveNativeReviewDiffView();1118 const NativeReviewDiffView = resolveNativeReviewDiffView();
1147 const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]);1119 const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]);
1148 const parsedDiff = useMemo(1120 const parsedDiff = useMemo(
...@@ -1155,8 +1127,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {...@@ -1155,8 +1127,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
1155 [nativeReviewDiffData.rows],1127 [nativeReviewDiffData.rows],
1156 );1128 );
1157 const nativeReviewDiffTheme = useMemo(1129 const nativeReviewDiffTheme = useMemo(
1158 () => createNativeReviewDiffTheme(appearanceScheme),1130 () => createNativeReviewDiffTheme(appearanceScheme, themeId),
1159 [appearanceScheme],1131 [appearanceScheme, themeId],
1160 );1132 );
1161 const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]);1133 const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]);
1162 const nativeThemeJson = useMemo(1134 const nativeThemeJson = useMemo(
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+6-5
...@@ -15,7 +15,7 @@ import type { EnvironmentId } from "@t3tools/contracts";...@@ -15,7 +15,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
15import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";15import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
16import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";16import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
17import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";17import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
18import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native";18import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
19import { Gesture, GestureDetector } from "react-native-gesture-handler";19import { Gesture, GestureDetector } from "react-native-gesture-handler";
20import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";20import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
21import { useSafeAreaInsets } from "react-native-safe-area-context";21import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -38,6 +38,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks";...@@ -38,6 +38,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
38import { useWorkspaceState } from "../../state/workspace";38import { useWorkspaceState } from "../../state/workspace";
39import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";39import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
40import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";40import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
41import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
41import {42import {
42 hasCustomHomeListOptions,43 hasCustomHomeListOptions,
43 PROJECT_SORT_OPTIONS,44 PROJECT_SORT_OPTIONS,
...@@ -102,6 +103,8 @@ function SidebarHeaderButtonGroup(props: {...@@ -102,6 +103,8 @@ function SidebarHeaderButtonGroup(props: {
102 readonly children: ReactNode;103 readonly children: ReactNode;
103 readonly colorScheme: "light" | "dark";104 readonly colorScheme: "light" | "dark";
104}) {105}) {
106 const fallbackBackground = useThemeColor("--color-glass-surface");
107 const fallbackBorder = useThemeColor("--color-header-border");
105 if (isLiquidGlassSupported) {108 if (isLiquidGlassSupported) {
106 return (109 return (
107 <LiquidGlassView110 <LiquidGlassView
...@@ -119,9 +122,7 @@ function SidebarHeaderButtonGroup(props: {...@@ -119,9 +122,7 @@ function SidebarHeaderButtonGroup(props: {
119 <View122 <View
120 style={[123 style={[
121 styles.headerButtonGroup,124 styles.headerButtonGroup,
122 props.colorScheme === "dark"125 { backgroundColor: fallbackBackground, borderColor: fallbackBorder },
123 ? { backgroundColor: "rgba(118,118,128,0.24)", borderColor: "rgba(255,255,255,0.08)" }
124 : { backgroundColor: "rgba(255,255,255,0.72)", borderColor: "rgba(0,0,0,0.08)" },
125 { borderWidth: StyleSheet.hairlineWidth },126 { borderWidth: StyleSheet.hairlineWidth },
126 ]}127 ]}
127 >128 >
...@@ -192,7 +193,7 @@ function ThreadNavigationSidebarPane(...@@ -192,7 +193,7 @@ function ThreadNavigationSidebarPane(
192 props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },193 props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },
193) {194) {
194 const insets = useSafeAreaInsets();195 const insets = useSafeAreaInsets();
195 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";196 const { themeAppearance: colorScheme } = useAppearancePreferences();
196 const projects = useProjects();197 const projects = useProjects();
197 const threads = useThreadShells();198 const threads = useThreadShells();
198 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();199 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+6-14
...@@ -27,7 +27,7 @@ import {...@@ -27,7 +27,7 @@ import {
27 useState,27 useState,
28 type ReactNode,28 type ReactNode,
29} from "react";29} from "react";
30import { Platform, Pressable, ScrollView, Switch, TextInput, View } from "react-native";30import { Platform, Pressable, ScrollView, TextInput, View } from "react-native";
31import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";31import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
32import { useSafeAreaInsets } from "react-native-safe-area-context";32import { useSafeAreaInsets } from "react-native-safe-area-context";
3333
...@@ -35,6 +35,7 @@ import { SymbolView } from "../../components/AppSymbol";...@@ -35,6 +35,7 @@ import { SymbolView } from "../../components/AppSymbol";
35import { AppText as Text } from "../../components/AppText";35import { AppText as Text } from "../../components/AppText";
36import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";36import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
37import { ProviderIcon } from "../../components/ProviderIcon";37import { ProviderIcon } from "../../components/ProviderIcon";
38import { ThemedSwitch } from "../../components/ThemedSwitch";
38import { cn } from "../../lib/cn";39import { cn } from "../../lib/cn";
39import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";40import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
40import { applyProviderOptionSelection } from "../../lib/providerOptions";41import { applyProviderOptionSelection } from "../../lib/providerOptions";
...@@ -46,10 +47,6 @@ import {...@@ -46,10 +47,6 @@ import {
46 nativeHeaderScrollEdgeEffects,47 nativeHeaderScrollEdgeEffects,
47} from "../../native/StackHeader";48} from "../../native/StackHeader";
48import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";49import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
49import {
50 NATIVE_SHEET_SURFACE_COLOR,
51 NATIVE_SHEET_SURFACE_CONTENT_STYLE,
52} from "../../native/sheet-surface";
53import { useNewTaskFlow } from "./new-task-flow-provider";50import { useNewTaskFlow } from "./new-task-flow-provider";
54import {51import {
55 createNativeMailSearchToolbarItem,52 createNativeMailSearchToolbarItem,
...@@ -270,7 +267,7 @@ function SwitchRow(props: {...@@ -270,7 +267,7 @@ function SwitchRow(props: {
270 )}267 )}
271 >268 >
272 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>269 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>
273 <Switch270 <ThemedSwitch
274 accessibilityLabel={props.label}271 accessibilityLabel={props.label}
275 onValueChange={props.onValueChange}272 onValueChange={props.onValueChange}
276 value={props.value}273 value={props.value}
...@@ -1128,9 +1125,8 @@ function ThreadSettingsChoiceScreen() {...@@ -1128,9 +1125,8 @@ function ThreadSettingsChoiceScreen() {
1128}1125}
11291126
1130function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) {1127function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) {
1131 const sheetBackground = String(useThemeColor("--color-sheet"));1128 const solidSheetBackground = String(useThemeColor("--color-sheet-solid"));
1132 const foreground = String(useThemeColor("--color-foreground"));1129 const foreground = String(useThemeColor("--color-foreground"));
1133 const nativeSheetBackground = NATIVE_SHEET_SURFACE_COLOR ?? sheetBackground;
1134 const presentation = useMemo(1130 const presentation = useMemo(
1135 () => ({1131 () => ({
1136 onClose: props.onClose,1132 onClose: props.onClose,
...@@ -1144,17 +1140,13 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)...@@ -1144,17 +1140,13 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)
1144 initialRouteName="ThreadSettingsModels"1140 initialRouteName="ThreadSettingsModels"
1145 screenOptions={{1141 screenOptions={{
1146 animation: "slide_from_right",1142 animation: "slide_from_right",
1147 contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE ?? {1143 contentStyle: { backgroundColor: solidSheetBackground },
1148 backgroundColor: nativeSheetBackground,
1149 },
1150 gestureEnabled: true,1144 gestureEnabled: true,
1151 headerBackButtonDisplayMode: "minimal",1145 headerBackButtonDisplayMode: "minimal",
1152 headerBackTitle: "",1146 headerBackTitle: "",
1153 headerShadowVisible: false,1147 headerShadowVisible: false,
1154 headerStyle: {1148 headerStyle: {
1155 backgroundColor: (NATIVE_LIQUID_GLASS_SUPPORTED1149 backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground,
1156 ? "transparent"
1157 : nativeSheetBackground) as unknown as string,
1158 },1150 },
1159 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,1151 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
1160 headerTintColor: foreground,1152 headerTintColor: foreground,
apps/mobile/src/features/threads/sidebar-filter-button.tsx+3-5
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { SymbolView } from "../../components/AppSymbol";1import { SymbolView } from "../../components/AppSymbol";
2import { Pressable, StyleSheet, useColorScheme } from "react-native";2import { Pressable, StyleSheet } from "react-native";
33
4import { useThemeColor } from "../../lib/useThemeColor";4import { useThemeColor } from "../../lib/useThemeColor";
55
...@@ -15,10 +15,8 @@ export function SidebarFilterButton(props: {...@@ -15,10 +15,8 @@ export function SidebarFilterButton(props: {
15}) {15}) {
16 const iconColor = useThemeColor("--color-foreground");16 const iconColor = useThemeColor("--color-foreground");
17 const pressedBackgroundColor = useThemeColor("--color-subtle");17 const pressedBackgroundColor = useThemeColor("--color-subtle");
18 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";18 const idleBackgroundColor = useThemeColor("--color-glass-surface");
19 const idleBackgroundColor =19 const borderColor = useThemeColor("--color-header-border");
20 colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)";
21 const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
2220
23 return (21 return (
24 <Pressable22 <Pressable
apps/mobile/src/features/threads/sidebar-header-actions.tsx+3-5
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { SymbolView } from "../../components/AppSymbol";1import { SymbolView } from "../../components/AppSymbol";
2import { Pressable, StyleSheet, View, useColorScheme } from "react-native";2import { Pressable, StyleSheet, View } from "react-native";
33
4import { useThemeColor } from "../../lib/useThemeColor";4import { useThemeColor } from "../../lib/useThemeColor";
55
...@@ -17,10 +17,8 @@ function FallbackHeaderButton(props: {...@@ -17,10 +17,8 @@ function FallbackHeaderButton(props: {
17}) {17}) {
18 const iconColor = useThemeColor("--color-foreground");18 const iconColor = useThemeColor("--color-foreground");
19 const pressedBackgroundColor = useThemeColor("--color-subtle");19 const pressedBackgroundColor = useThemeColor("--color-subtle");
20 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";20 const idleBackgroundColor = useThemeColor("--color-glass-surface");
21 const idleBackgroundColor =21 const borderColor = useThemeColor("--color-header-border");
22 colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)";
23 const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
2422
25 return (23 return (
26 <Pressable24 <Pressable
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+7-9
...@@ -1,19 +1,16 @@...@@ -1,19 +1,16 @@
1import {1import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native";
2 DarkTheme,
3 DefaultTheme,
4 NavigationContainer,
5 NavigationIndependentTree,
6} from "@react-navigation/native";
7import {2import {
8 createNativeStackNavigator,3 createNativeStackNavigator,
9 type NativeStackNavigationOptions,4 type NativeStackNavigationOptions,
10} from "@react-navigation/native-stack";5} from "@react-navigation/native-stack";
11import type { ReactNode } from "react";6import type { ReactNode } from "react";
12import { Platform, useColorScheme } from "react-native";7import { Platform } from "react-native";
138
14import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle";9import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle";
15import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";10import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
16import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";11import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
12import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme";
13import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1714
18const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);15const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
1916
...@@ -56,11 +53,12 @@ const SidebarStack = createNativeStackNavigator();...@@ -56,11 +53,12 @@ const SidebarStack = createNativeStackNavigator();
56 * navigation hooks used for header configuration inside the pane.53 * navigation hooks used for header configuration inside the pane.
57 */54 */
58export function SidebarNavigationShell(props: { readonly children: ReactNode }) {55export function SidebarNavigationShell(props: { readonly children: ReactNode }) {
59 const colorScheme = useColorScheme();56 const { themeAppearance } = useAppearancePreferences();
57 const navigationTheme = useMobileNavigationTheme(themeAppearance);
6058
61 return (59 return (
62 <NavigationIndependentTree>60 <NavigationIndependentTree>
63 <NavigationContainer theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}>61 <NavigationContainer theme={navigationTheme}>
64 <SidebarStack.Navigator62 <SidebarStack.Navigator
65 screenOptions={SIDEBAR_SCREEN_OPTIONS}63 screenOptions={SIDEBAR_SCREEN_OPTIONS}
66 initialRouteName="SidebarThreads"64 initialRouteName="SidebarThreads"
apps/mobile/src/features/threads/thread-list-items.tsx+20-10
...@@ -7,8 +7,9 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state...@@ -7,8 +7,9 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state
7import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
8import { SymbolView } from "../../components/AppSymbol";8import { SymbolView } from "../../components/AppSymbol";
9import { memo, useCallback, useMemo, type ComponentProps } from "react";9import { memo, useCallback, useMemo, type ComponentProps } from "react";
10import { Pressable, useColorScheme, useWindowDimensions, View } from "react-native";10import { Pressable, useWindowDimensions, View } from "react-native";
11import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";11import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
12import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
12import Svg, { Circle, Path } from "react-native-svg";13import Svg, { Circle, Path } from "react-native-svg";
1314
14import { AppText as Text } from "../../components/AppText";15import { AppText as Text } from "../../components/AppText";
...@@ -17,6 +18,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";...@@ -17,6 +18,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
17import { cn } from "../../lib/cn";18import { cn } from "../../lib/cn";
18import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";19import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
19import { relativeTime } from "../../lib/time";20import { relativeTime } from "../../lib/time";
21import { themeColorWithAlpha } from "../../lib/mobileTheme";
20import { useThemeColor } from "../../lib/useThemeColor";22import { useThemeColor } from "../../lib/useThemeColor";
21import type { PendingNewTask } from "../../state/use-pending-new-tasks";23import type { PendingNewTask } from "../../state/use-pending-new-tasks";
22import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";24import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";
...@@ -38,10 +40,7 @@ export type ThreadListVariant = "compact" | "sidebar";...@@ -38,10 +40,7 @@ export type ThreadListVariant = "compact" | "sidebar";
38export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;40export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;
39const SIDEBAR_ROW_RADIUS = 12;41const SIDEBAR_ROW_RADIUS = 12;
4042
41function pullRequestTintColor(43function pullRequestTintColor(state: ThreadPr["state"], colorScheme: "light" | "dark") {
42 state: ThreadPr["state"],
43 colorScheme: ReturnType<typeof useColorScheme>,
44) {
45 const dark = colorScheme === "dark";44 const dark = colorScheme === "dark";
46 switch (state) {45 switch (state) {
47 case "open":46 case "open":
...@@ -440,7 +439,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -440,7 +439,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
440 >["simultaneousWithExternalGesture"];439 >["simultaneousWithExternalGesture"];
441}) {440}) {
442 const { width: windowWidth } = useWindowDimensions();441 const { width: windowWidth } = useWindowDimensions();
443 const colorScheme = useColorScheme();442 const { themeAppearance: colorScheme } = useAppearancePreferences();
444 const compact = props.variant === "compact";443 const compact = props.variant === "compact";
445 const selected = props.selected === true;444 const selected = props.selected === true;
446 // Recycling-safe: resets when the list container is reused for another445 // Recycling-safe: resets when the list container is reused for another
...@@ -453,6 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -453,6 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
453 const drawerColor = useThemeColor("--color-drawer");452 const drawerColor = useThemeColor("--color-drawer");
454 const pressedBackgroundColor = useThemeColor("--color-subtle");453 const pressedBackgroundColor = useThemeColor("--color-subtle");
455 const selectedBackgroundColor = useThemeColor("--color-user-bubble");454 const selectedBackgroundColor = useThemeColor("--color-user-bubble");
455 const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground");
456456
457 const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =457 const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
458 props;458 props;
...@@ -467,10 +467,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -467,10 +467,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
467 );467 );
468468
469 const backgroundColor = compact ? screenColor : drawerColor;469 const backgroundColor = compact ? screenColor : drawerColor;
470 const effectivePressedBackground = selected ? "rgba(255,255,255,0.16)" : pressedBackgroundColor;470 const effectivePressedBackground = selected
471 ? themeColorWithAlpha(String(selectedForegroundColor), 0.16)
472 : pressedBackgroundColor;
471 const effectiveStatus =473 const effectiveStatus =
472 selected && status474 selected && status
473 ? { ...status, pillClassName: "bg-white/20", textClassName: "text-white" }475 ? {
476 ...status,
477 pillClassName: "bg-user-bubble-foreground/20",
478 textClassName: "text-user-bubble-foreground",
479 }
474 : status;480 : status;
475481
476 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);482 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
...@@ -538,11 +544,15 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -538,11 +544,15 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
538 <View className="flex-row items-center gap-0.5">544 <View className="flex-row items-center gap-0.5">
539 <PullRequestIcon545 <PullRequestIcon
540 size={compact ? 13 : 11}546 size={compact ? 13 : 11}
541 color={selected ? "#ffffff" : pullRequestTintColor(pr.state, colorScheme)}547 color={
548 selected
549 ? String(selectedForegroundColor)
550 : pullRequestTintColor(pr.state, colorScheme)
551 }
542 />552 />
543 <Text553 <Text
544 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${554 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${
545 selected ? "text-white" : pr.textClassName555 selected ? "text-user-bubble-foreground" : pr.textClassName
546 }`}556 }`}
547 >557 >
548 {pr.label}558 {pr.label}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+9-13
...@@ -6,14 +6,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state...@@ -6,14 +6,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state
6import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";6import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
7import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
8import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";8import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
9import {9import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
10 Alert,
11 Platform,
12 Pressable,
13 useColorScheme,
14 useWindowDimensions,
15 View,
16} from "react-native";
17import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";10import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
1811
19import { SymbolView } from "../../components/AppSymbol";12import { SymbolView } from "../../components/AppSymbol";
...@@ -27,6 +20,7 @@ import { useThemeColor } from "../../lib/useThemeColor";...@@ -27,6 +20,7 @@ import { useThemeColor } from "../../lib/useThemeColor";
27import type { PendingNewTask } from "../../state/use-pending-new-tasks";20import type { PendingNewTask } from "../../state/use-pending-new-tasks";
28import { useThreadPr } from "../../state/use-thread-pr";21import { useThreadPr } from "../../state/use-thread-pr";
29import { ThreadSwipeable } from "../home/thread-swipe-actions";22import { ThreadSwipeable } from "../home/thread-swipe-actions";
23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
30import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";24import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
31import {25import {
32 resolveThreadListV2SnoozeMenuSelection,26 resolveThreadListV2SnoozeMenuSelection,
...@@ -120,7 +114,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS...@@ -120,7 +114,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS
120 readonly onToggle: () => void;114 readonly onToggle: () => void;
121 readonly pane?: "screen" | "sidebar";115 readonly pane?: "screen" | "sidebar";
122}) {116}) {
123 const colorScheme = useColorScheme();117 const { themeAppearance: colorScheme } = useAppearancePreferences();
124 return (118 return (
125 <Pressable119 <Pressable
126 accessibilityHint={120 accessibilityHint={
...@@ -660,8 +654,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -660,8 +654,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
660 ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`654 ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`
661 : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`;655 : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`;
662656
663 // The sidebar pane fills selected rows with the accent color (matching the657 // The sidebar pane fills selected rows with the theme's message surface, so
664 // v1 sidebar), so every piece of row text needs a white-on-accent variant.658 // every piece of row text must use that surface's paired foreground.
665 const cardContent = (659 const cardContent = (
666 <>660 <>
667 <View className="flex-row items-center gap-1.5">661 <View className="flex-row items-center gap-1.5">
...@@ -689,7 +683,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -689,7 +683,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
689 <Text683 <Text
690 className={cn(684 className={cn(
691 "text-xs tabular-nums",685 "text-xs tabular-nums",
692 selected ? "text-white" : (statusLabel?.className ?? "text-foreground-tertiary"),686 selected
687 ? "text-user-bubble-foreground"
688 : (statusLabel?.className ?? "text-foreground-tertiary"),
693 )}689 )}
694 >690 >
695 {statusLabel?.label ?? timeLabel}691 {statusLabel?.label ?? timeLabel}
...@@ -766,7 +762,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -766,7 +762,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
766 {pr ? (762 {pr ? (
767 <Text763 <Text
768 accessibilityLabel={pr.accessibilityLabel}764 accessibilityLabel={pr.accessibilityLabel}
769 className={cn("text-xs", selected ? "text-white" : pr.textClassName)}765 className={cn("text-xs", selected ? "text-user-bubble-foreground" : pr.textClassName)}
770 style={{ fontFamily: MONO_FONT }}766 style={{ fontFamily: MONO_FONT }}
771 >767 >
772 #{pr.label}768 #{pr.label}
apps/mobile/src/features/threads/thread-work-log.tsx+4-5
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";2import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
3import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native";3import { LayoutAnimation, Pressable, ScrollView, View } from "react-native";
44
5import { AppText as Text } from "../../components/AppText";5import { AppText as Text } from "../../components/AppText";
6import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";6import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
7import { cn } from "../../lib/cn";7import { cn } from "../../lib/cn";
8import type { ThreadFeedActivity } from "../../lib/threadActivity";8import type { ThreadFeedActivity } from "../../lib/threadActivity";
9import { MOBILE_TYPOGRAPHY } from "../../lib/typography";9import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
10import { useThemeColor } from "../../lib/useThemeColor";
10import Animated, { FadeIn } from "react-native-reanimated";11import Animated, { FadeIn } from "react-native-reanimated";
1112
12const WORK_LOG_LAYOUT_ANIMATION = {13const WORK_LOG_LAYOUT_ANIMATION = {
...@@ -127,8 +128,7 @@ export function ThreadWorkLog(props: {...@@ -127,8 +128,7 @@ export function ThreadWorkLog(props: {
127 readonly onCopyRow: (rowId: string, value: string) => void;128 readonly onCopyRow: (rowId: string, value: string) => void;
128 readonly onToggleRow: (rowId: string) => void;129 readonly onToggleRow: (rowId: string) => void;
129}) {130}) {
130 const colorScheme = useColorScheme();131 const pressedBackground = useThemeColor("--color-subtle");
131 const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
132 const rows = visibleWorkLogActivities(props.activities).map((activity) => ({132 const rows = visibleWorkLogActivities(props.activities).map((activity) => ({
133 ...activity,133 ...activity,
134 detail: compactActivityDetail(activity.detail),134 detail: compactActivityDetail(activity.detail),
...@@ -281,8 +281,7 @@ export function ThreadWorkGroupToggle(props: {...@@ -281,8 +281,7 @@ export function ThreadWorkGroupToggle(props: {
281 readonly onlyToolActivities: boolean;281 readonly onlyToolActivities: boolean;
282 readonly onToggle: () => void;282 readonly onToggle: () => void;
283}) {283}) {
284 const colorScheme = useColorScheme();284 const pressedBackground = useThemeColor("--color-subtle");
285 const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
286 const noun = props.onlyToolActivities285 const noun = props.onlyToolActivities
287 ? props.hiddenCount === 1286 ? props.hiddenCount === 1
288 ? "tool call"287 ? "tool call"
apps/mobile/src/features/usage/usageProviders.ts+2-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { UsageProviderKind } from "@t3tools/contracts";1import type { UsageProviderKind } from "@t3tools/contracts";
2import { useColorScheme } from "react-native";2import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
33
4/**4/**
5 * Series and table order. The chart stacks providers from the bottom in this5 * Series and table order. The chart stacks providers from the bottom in this
...@@ -17,7 +17,7 @@ export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {...@@ -17,7 +17,7 @@ export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
17 * with the theme or its bars vanish against the matching background.17 * with the theme or its bars vanish against the matching background.
18 */18 */
19export function useProviderColors(): Record<UsageProviderKind, string> {19export function useProviderColors(): Record<UsageProviderKind, string> {
20 const scheme = useColorScheme();20 const { themeAppearance: scheme } = useAppearancePreferences();
21 return {21 return {
22 claude: "#d97757",22 claude: "#d97757",
23 codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",23 codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
apps/mobile/src/lib/mobileDefaultTheme.ts created+139
...@@ -0,0 +1,139 @@
1import type { MobileThemeVariables } from "./mobileTheme";
2
3/** The existing T3 Code mobile palette, retained as the upgrade-safe default. */
4export const DEFAULT_MOBILE_THEME_VARIABLES = {
5 light: {
6 "--color-screen": "#f2f2f7",
7 "--color-sheet": "rgba(242, 242, 247, 0.98)",
8 "--color-sheet-solid": "#f2f2f7",
9 "--color-card": "#ffffff",
10 "--color-card-alt": "#f5f5f5",
11 "--color-card-translucent": "rgba(255, 255, 255, 0.8)",
12 "--color-foreground": "#262626",
13 "--color-foreground-secondary": "#525252",
14 "--color-foreground-muted": "#737373",
15 "--color-foreground-tertiary": "#8e8e93",
16 "--color-border": "rgba(0, 0, 0, 0.08)",
17 "--color-border-subtle": "rgba(0, 0, 0, 0.06)",
18 "--color-separator": "rgba(0, 0, 0, 0.04)",
19 "--color-subtle": "rgba(0, 0, 0, 0.04)",
20 "--color-subtle-strong": "rgba(0, 0, 0, 0.08)",
21 "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)",
22 "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)",
23 "--color-inline-skill-foreground": "#a21caf",
24 "--color-primary": "#262626",
25 "--color-primary-foreground": "#ffffff",
26 "--color-primary-shadow": "#000000",
27 "--color-secondary": "#ffffff",
28 "--color-secondary-foreground": "#262626",
29 "--color-secondary-border": "rgba(0, 0, 0, 0.08)",
30 "--color-switch-active-track": "#34c759",
31 "--color-switch-active-thumb": "#ffffff",
32 "--color-switch-inactive-track": "rgba(0, 0, 0, 0.08)",
33 "--color-switch-inactive-thumb": "#8e8e93",
34 "--color-danger": "#fef2f2",
35 "--color-danger-border": "rgba(239, 68, 68, 0.12)",
36 "--color-danger-foreground": "#dc2626",
37 "--color-input": "#ffffff",
38 "--color-input-border": "rgba(0, 0, 0, 0.1)",
39 "--color-sidebar-search": "rgba(118, 118, 128, 0.12)",
40 "--color-placeholder": "#737373",
41 "--color-icon": "#262626",
42 "--color-icon-muted": "#525252",
43 "--color-icon-subtle": "#a3a3a3",
44 "--color-header": "rgba(255, 255, 255, 0.97)",
45 "--color-header-border": "rgba(0, 0, 0, 0.06)",
46 "--color-glass-surface": "rgba(255, 255, 255, 0.72)",
47 "--color-glass-tint": "rgba(255, 255, 255, 0.18)",
48 "--color-status-bar": "#f2f2f7",
49 "--color-md-body": "#111111",
50 "--color-md-strong": "#000000",
51 "--color-md-link": "#2563eb",
52 "--color-md-blockquote-border": "rgba(0, 0, 0, 0.08)",
53 "--color-md-blockquote-bg": "rgba(0, 0, 0, 0.02)",
54 "--color-md-code-bg": "rgba(0, 0, 0, 0.04)",
55 "--color-md-code-text": "#262626",
56 "--color-md-user-code-bg": "rgba(255, 255, 255, 0.22)",
57 "--color-md-user-code-text": "#ffffff",
58 "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.16)",
59 "--color-md-user-fence-text": "#ffffff",
60 "--color-md-hr": "rgba(0, 0, 0, 0.08)",
61 "--color-user-bubble": "#007aff",
62 "--color-user-bubble-foreground": "#ffffff",
63 "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)",
64 "--color-user-bubble-skill-foreground": "#f0abfc",
65 "--color-backdrop": "rgba(0, 0, 0, 0.22)",
66 "--color-drawer": "rgba(255, 255, 255, 0.99)",
67 "--color-drawer-shadow": "rgba(0, 0, 0, 0.12)",
68 "--color-dot-separator": "rgba(0, 0, 0, 0.2)",
69 "--color-wordmark": "#262626",
70 "--color-chevron": "rgba(0, 0, 0, 0.2)",
71 },
72 dark: {
73 "--color-screen": "#0a0a0a",
74 "--color-sheet": "rgba(14, 14, 14, 0.98)",
75 "--color-sheet-solid": "#0e0e0e",
76 "--color-card": "#171717",
77 "--color-card-alt": "#1c1c1c",
78 "--color-card-translucent": "rgba(17, 17, 17, 0.8)",
79 "--color-foreground": "#f5f5f5",
80 "--color-foreground-secondary": "#a3a3a3",
81 "--color-foreground-muted": "#8e8e93",
82 "--color-foreground-tertiary": "#636366",
83 "--color-border": "rgba(255, 255, 255, 0.06)",
84 "--color-border-subtle": "rgba(255, 255, 255, 0.04)",
85 "--color-separator": "rgba(255, 255, 255, 0.03)",
86 "--color-subtle": "rgba(255, 255, 255, 0.04)",
87 "--color-subtle-strong": "rgba(255, 255, 255, 0.08)",
88 "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)",
89 "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)",
90 "--color-inline-skill-foreground": "#f0abfc",
91 "--color-primary": "#f5f5f5",
92 "--color-primary-foreground": "#0a0a0a",
93 "--color-primary-shadow": "#000000",
94 "--color-secondary": "rgba(255, 255, 255, 0.04)",
95 "--color-secondary-foreground": "#f5f5f5",
96 "--color-secondary-border": "rgba(255, 255, 255, 0.06)",
97 "--color-switch-active-track": "#30d158",
98 "--color-switch-active-thumb": "#ffffff",
99 "--color-switch-inactive-track": "rgba(255, 255, 255, 0.06)",
100 "--color-switch-inactive-thumb": "#8e8e93",
101 "--color-danger": "rgba(239, 68, 68, 0.14)",
102 "--color-danger-border": "rgba(248, 113, 113, 0.18)",
103 "--color-danger-foreground": "#fca5a5",
104 "--color-input": "#141414",
105 "--color-input-border": "rgba(255, 255, 255, 0.08)",
106 "--color-sidebar-search": "rgba(118, 118, 128, 0.24)",
107 "--color-placeholder": "#8e8e93",
108 "--color-icon": "#f5f5f5",
109 "--color-icon-muted": "#a3a3a3",
110 "--color-icon-subtle": "#8e8e93",
111 "--color-header": "rgba(10, 10, 10, 0.97)",
112 "--color-header-border": "rgba(255, 255, 255, 0.06)",
113 "--color-glass-surface": "rgba(23, 23, 23, 0.78)",
114 "--color-glass-tint": "rgba(23, 23, 23, 0.24)",
115 "--color-status-bar": "#0a0a0a",
116 "--color-md-body": "#e5e5e5",
117 "--color-md-strong": "#f5f5f5",
118 "--color-md-link": "#60a5fa",
119 "--color-md-blockquote-border": "rgba(255, 255, 255, 0.1)",
120 "--color-md-blockquote-bg": "rgba(255, 255, 255, 0.03)",
121 "--color-md-code-bg": "rgba(255, 255, 255, 0.06)",
122 "--color-md-code-text": "#e5e5e5",
123 "--color-md-user-code-bg": "rgba(255, 255, 255, 0.18)",
124 "--color-md-user-code-text": "#ffffff",
125 "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.28)",
126 "--color-md-user-fence-text": "#ffffff",
127 "--color-md-hr": "rgba(255, 255, 255, 0.08)",
128 "--color-user-bubble": "#0a84ff",
129 "--color-user-bubble-foreground": "#ffffff",
130 "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)",
131 "--color-user-bubble-skill-foreground": "#f0abfc",
132 "--color-backdrop": "rgba(0, 0, 0, 0.48)",
133 "--color-drawer": "rgba(14, 14, 14, 0.99)",
134 "--color-drawer-shadow": "rgba(0, 0, 0, 0.32)",
135 "--color-dot-separator": "rgba(255, 255, 255, 0.2)",
136 "--color-wordmark": "#f5f5f5",
137 "--color-chevron": "rgba(255, 255, 255, 0.2)",
138 },
139} as const satisfies Readonly<Record<"light" | "dark", MobileThemeVariables>>;
apps/mobile/src/lib/mobileTheme.test.ts created+231
...@@ -0,0 +1,231 @@
1import { describe, expect, it } from "vite-plus/test";
2import * as NodeFS from "node:fs";
3
4import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
5import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme";
6
7import {
8 createMobileThemePairPatch,
9 createMobileThemeSelectionPatch,
10 createMobileThemeVariables,
11 DEFAULT_MOBILE_THEME_ID,
12 getMobileThemePreviewColors,
13 getMobileThemeVariables,
14 MOBILE_THEME_IDS,
15 normalizeMobileThemeId,
16 normalizeMobileThemeMode,
17 resolveMobileThemeIds,
18 themeColorWithAlpha,
19 themeColorToNativeColor,
20} from "./mobileTheme";
21
22function relativeLuminance(hex: string): number {
23 const channels = hex
24 .slice(1)
25 .match(/.{2}/g)!
26 .map((channel) => Number.parseInt(channel, 16) / 255)
27 .map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
28 return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
29}
30
31function contrastRatio(first: string, second: string): number {
32 const firstLuminance = relativeLuminance(first);
33 const secondLuminance = relativeLuminance(second);
34 return (
35 (Math.max(firstLuminance, secondLuminance) + 0.05) /
36 (Math.min(firstLuminance, secondLuminance) + 0.05)
37 );
38}
39
40function compositeOver(overlay: string, background: string): string {
41 const overlayMatch = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(overlay)!;
42 const backgroundChannels = background
43 .slice(1)
44 .match(/.{2}/g)!
45 .map((channel) => Number.parseInt(channel, 16));
46 const alpha = Number(overlayMatch[4]);
47 const channels = [1, 2, 3].map((index) =>
48 Math.round(Number(overlayMatch[index]) * alpha + backgroundChannels[index - 1]! * (1 - alpha)),
49 );
50 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
51}
52
53describe("mobile themes", () => {
54 it("declares every runtime theme variable in the static stylesheet", () => {
55 const stylesheet = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8");
56 const stylesheetVariables = new Set(
57 Array.from(stylesheet.matchAll(/--color-[a-z0-9-]+/g), ([variable]) => variable),
58 );
59
60 expect(Array.from(stylesheetVariables).sort()).toEqual(
61 Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort(),
62 );
63 });
64
65 it("shares all built-in desktop palettes", () => {
66 expect(BUILT_IN_THEMES.map((theme) => theme.id)).toEqual(BUILT_IN_THEME_IDS);
67 for (const themeId of BUILT_IN_THEME_IDS) {
68 expect(getMobileThemeVariables(themeId, "light")["--color-screen"]).toMatch(/^#/);
69 expect(getMobileThemeVariables(themeId, "dark")["--color-screen"]).toMatch(/^#/);
70 }
71 });
72
73 it("preserves the existing mobile palette as the default", () => {
74 expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")["--color-screen"]).toBe(
75 "#f2f2f7",
76 );
77 expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "dark")["--color-screen"]).toBe(
78 "#0a0a0a",
79 );
80 expect(
81 getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")[
82 "--color-user-bubble-skill-foreground"
83 ],
84 ).toBe("#f0abfc");
85 });
86
87 it("applies palette overrides on top of the selected built-in theme", () => {
88 const variables = getMobileThemeVariables("ocean", "dark", {
89 "--color-primary": "#123456",
90 });
91
92 expect(variables["--color-primary"]).toBe("#123456");
93 expect(variables["--color-screen"]).toMatch(/^#/);
94 });
95
96 it("uses the same preview roles and standard artwork as desktop", () => {
97 expect(getMobileThemePreviewColors(DEFAULT_MOBILE_THEME_ID, "light")).toEqual({
98 canvas: "#fcfcfc",
99 accent: "#f4f4f5",
100 messageAction: "#4f46e5",
101 });
102 const desktopOcean = BUILT_IN_THEMES.find((theme) => theme.id === "ocean")!;
103 expect(getMobileThemePreviewColors("ocean", "light")).toEqual({
104 canvas: themeColorToNativeColor(desktopOcean.colors.canvas),
105 accent: themeColorToNativeColor(desktopOcean.colors.accent),
106 messageAction: themeColorToNativeColor(desktopOcean.colors.messageAction),
107 });
108 });
109
110 it("normalizes persisted theme preferences", () => {
111 expect(normalizeMobileThemeId("ocean")).toBe("ocean");
112 expect(normalizeMobileThemeId("missing-theme")).toBe(DEFAULT_MOBILE_THEME_ID);
113 expect(normalizeMobileThemeMode("dark")).toBe("dark");
114 expect(normalizeMobileThemeMode("sepia")).toBe("system");
115 });
116
117 it("migrates one theme choice to both appearances and preserves independent choices", () => {
118 expect(resolveMobileThemeIds({ themeId: "grove" })).toEqual({
119 light: "grove",
120 dark: "grove",
121 });
122 expect(
123 resolveMobileThemeIds({ themeId: "grove", lightThemeId: "iris", darkThemeId: "ocean" }),
124 ).toEqual({ light: "iris", dark: "ocean" });
125 expect(resolveMobileThemeIds({ themeId: "grove", lightThemeId: "missing" })).toEqual({
126 light: DEFAULT_MOBILE_THEME_ID,
127 dark: "grove",
128 });
129 });
130
131 it("changes either theme without switching the active appearance", () => {
132 const themeIds = { light: "t3-chat", dark: "grove" } as const;
133 expect(createMobileThemeSelectionPatch(themeIds, "light", "dark", "ocean")).toEqual({
134 lightThemeId: "t3-chat",
135 darkThemeId: "ocean",
136 themeId: "t3-chat",
137 });
138 expect(createMobileThemeSelectionPatch(themeIds, "light", "light", "iris")).toEqual({
139 lightThemeId: "iris",
140 darkThemeId: "grove",
141 themeId: "iris",
142 });
143 });
144
145 it("changes both appearance themes from the card action", () => {
146 expect(createMobileThemePairPatch("ember")).toEqual({
147 lightThemeId: "ember",
148 darkThemeId: "ember",
149 themeId: "ember",
150 });
151 });
152
153 it("converts OKLCH colors to React Native sRGB ColorValues", () => {
154 expect(themeColorToNativeColor("oklch(1 0 0)")).toBe("#ffffff");
155 expect(themeColorToNativeColor("oklch(0 0 0)")).toBe("#000000");
156 expect(themeColorToNativeColor("#123456")).toBe("#123456");
157 });
158
159 it("changes native palette color opacity for fades", () => {
160 expect(themeColorWithAlpha("#123456", 0)).toBe("rgba(18, 52, 86, 0)");
161 expect(themeColorWithAlpha("rgba(18, 52, 86, 0.98)", 0)).toBe("rgba(18, 52, 86, 0)");
162 });
163
164 it("maps semantic palette roles onto every mobile color variable", () => {
165 const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light");
166 expect(Object.keys(variables)).toHaveLength(65);
167 expect(variables["--color-sheet-solid"]).toBe(
168 themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome),
169 );
170 expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]);
171 expect(variables["--color-primary-shadow"]).toBe("#000000");
172 expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)");
173 expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)");
174 expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/);
175 expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort()).toEqual(
176 Object.keys(variables).sort(),
177 );
178 expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.dark).sort()).toEqual(
179 Object.keys(variables).sort(),
180 );
181 });
182
183 it("keeps every built-in shadow and backdrop black-based in dark mode", () => {
184 for (const theme of BUILT_IN_THEMES) {
185 const variables = getMobileThemeVariables(normalizeMobileThemeId(theme.id), "dark");
186 expect(variables["--color-primary-shadow"]).toBe("#000000");
187 expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)");
188 expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)");
189 }
190 });
191
192 it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => {
193 for (const themeId of MOBILE_THEME_IDS) {
194 for (const appearance of ["light", "dark"] as const) {
195 const variables = getMobileThemeVariables(themeId, appearance);
196 expect(
197 contrastRatio(variables["--color-placeholder"], variables["--color-input"]),
198 ).toBeGreaterThanOrEqual(4.5);
199 }
200 }
201
202 for (const themeId of BUILT_IN_THEME_IDS) {
203 for (const appearance of ["light", "dark"] as const) {
204 const variables = getMobileThemeVariables(themeId, appearance);
205 expect(
206 contrastRatio(
207 variables["--color-user-bubble-foreground"],
208 variables["--color-user-bubble"],
209 ),
210 ).toBeGreaterThanOrEqual(4.5);
211 expect(
212 contrastRatio(
213 variables["--color-user-bubble-skill-foreground"],
214 variables["--color-user-bubble"],
215 ),
216 ).toBeGreaterThanOrEqual(4.5);
217 expect(variables["--color-user-bubble-skill-foreground"]).not.toBe(
218 variables["--color-user-bubble-foreground"],
219 );
220 const fenceSurface = compositeOver(
221 variables["--color-md-user-fence-bg"],
222 variables["--color-user-bubble"],
223 );
224 expect(fenceSurface).not.toBe(variables["--color-user-bubble"]);
225 expect(
226 contrastRatio(variables["--color-md-user-fence-text"], fenceSurface),
227 ).toBeGreaterThanOrEqual(4.5);
228 }
229 }
230 });
231});
apps/mobile/src/lib/mobileTheme.ts created+314
...@@ -0,0 +1,314 @@
1import {
2 BUILT_IN_THEMES,
3 getThemeColorsForAppearance,
4 MOBILE_DEFAULT_THEME_ID,
5 MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS,
6 type MobileThemeId as SharedMobileThemeId,
7 type ThemeAppearance,
8 type ThemeColors,
9} from "@t3tools/shared/themePalettes";
10import {
11 STANDARD_THEME_PREVIEW_COLORS,
12 type ThemePreviewColors,
13} from "@t3tools/shared/themePreview";
14import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme";
15
16export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID;
17export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS;
18export type MobileThemeId = SharedMobileThemeId;
19export type MobileThemeAppearance = ThemeAppearance;
20export type MobileThemeMode = MobileThemeAppearance | "system";
21export type MobileThemeIds = Readonly<Record<MobileThemeAppearance, MobileThemeId>>;
22
23export const MOBILE_THEME_OPTIONS: ReadonlyArray<{
24 readonly id: MobileThemeId;
25 readonly label: string;
26}> = [
27 { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" },
28 ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })),
29];
30
31type MobileThemeVariable = `--color-${string}`;
32export type MobileThemeVariables = Readonly<Record<MobileThemeVariable, string>>;
33
34export function normalizeMobileThemeId(value: unknown): MobileThemeId {
35 return typeof value === "string" && (MOBILE_THEME_IDS as readonly string[]).includes(value)
36 ? (value as MobileThemeId)
37 : DEFAULT_MOBILE_THEME_ID;
38}
39
40export function normalizeMobileThemeMode(value: unknown): MobileThemeMode {
41 return value === "light" || value === "dark" || value === "system" ? value : "system";
42}
43
44export function resolveMobileThemeIds(preferences: {
45 readonly themeId?: unknown;
46 readonly lightThemeId?: unknown;
47 readonly darkThemeId?: unknown;
48}): MobileThemeIds {
49 const legacyThemeId = normalizeMobileThemeId(preferences.themeId);
50 return {
51 light:
52 preferences.lightThemeId === undefined
53 ? legacyThemeId
54 : normalizeMobileThemeId(preferences.lightThemeId),
55 dark:
56 preferences.darkThemeId === undefined
57 ? legacyThemeId
58 : normalizeMobileThemeId(preferences.darkThemeId),
59 };
60}
61
62export function createMobileThemeSelectionPatch(
63 themeIds: MobileThemeIds,
64 activeAppearance: MobileThemeAppearance,
65 selectedAppearance: MobileThemeAppearance,
66 value: MobileThemeId,
67) {
68 const nextThemeIds: MobileThemeIds = {
69 light: selectedAppearance === "light" ? value : themeIds.light,
70 dark: selectedAppearance === "dark" ? value : themeIds.dark,
71 };
72 return {
73 lightThemeId: nextThemeIds.light,
74 darkThemeId: nextThemeIds.dark,
75 // Keep older OTA bundles on the theme for the appearance currently in use.
76 themeId: nextThemeIds[activeAppearance],
77 };
78}
79
80export function createMobileThemePairPatch(value: MobileThemeId) {
81 return {
82 lightThemeId: value,
83 darkThemeId: value,
84 themeId: value,
85 };
86}
87
88const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/;
89
90function linearToSrgb(value: number): number {
91 const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
92 return Math.round(Math.min(1, Math.max(0, converted)) * 255);
93}
94
95/** React Native does not accept OKLCH ColorValues, so palettes cross the app boundary as sRGB. */
96export function themeColorToNativeColor(value: string): string {
97 const match = OKLCH_PATTERN.exec(value);
98 if (!match) return value;
99
100 const lightness = Number(match[1]);
101 const chroma = Number(match[2]);
102 const hue = (Number(match[3]) * Math.PI) / 180;
103 const alpha = match[4] === undefined ? 1 : Number(match[4]);
104 const a = chroma * Math.cos(hue);
105 const b = chroma * Math.sin(hue);
106 const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b;
107 const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b;
108 const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b;
109 const l = lPrime ** 3;
110 const m = mPrime ** 3;
111 const s = sPrime ** 3;
112 const red = linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
113 const green = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
114 const blue = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s);
115
116 return alpha < 1
117 ? `rgba(${red}, ${green}, ${blue}, ${Number(alpha.toFixed(4))})`
118 : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
119}
120
121function nativeColors(colors: ThemeColors): ThemeColors {
122 return Object.fromEntries(
123 Object.entries(colors).map(([role, color]) => [role, themeColorToNativeColor(color)]),
124 ) as ThemeColors;
125}
126
127function withAlpha(color: string, alpha: number): string {
128 const hex = color.startsWith("#") ? color.slice(1) : "";
129 if (hex.length !== 6) return color;
130 const [red, green, blue] = [0, 2, 4].map((offset) =>
131 Number.parseInt(hex.slice(offset, offset + 2), 16),
132 );
133 return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
134}
135
136function rgbChannels(color: string): readonly [number, number, number] | null {
137 const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color);
138 return match
139 ? [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)]
140 : null;
141}
142
143function relativeLuminance(channels: readonly [number, number, number]): number {
144 const [red, green, blue] = channels.map((channel) => {
145 const value = channel / 255;
146 return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
147 });
148 return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!;
149}
150
151function contrastRatio(
152 first: readonly [number, number, number],
153 second: readonly [number, number, number],
154): number {
155 const firstLuminance = relativeLuminance(first);
156 const secondLuminance = relativeLuminance(second);
157 return (
158 (Math.max(firstLuminance, secondLuminance) + 0.05) /
159 (Math.min(firstLuminance, secondLuminance) + 0.05)
160 );
161}
162
163/** Preserve the theme's action hue while making it readable as skill text on a message bubble. */
164function readableMessageAccent(accent: string, surface: string): string {
165 const accentChannels = rgbChannels(accent);
166 const surfaceChannels = rgbChannels(surface);
167 if (
168 !accentChannels ||
169 !surfaceChannels ||
170 contrastRatio(accentChannels, surfaceChannels) >= 4.5
171 ) {
172 return accent;
173 }
174
175 const black = [0, 0, 0] as const;
176 const white = [255, 255, 255] as const;
177 const target =
178 contrastRatio(black, surfaceChannels) >= contrastRatio(white, surfaceChannels) ? black : white;
179 let readable: readonly [number, number, number] = target;
180 let lowerAmount = 0;
181 let upperAmount = 1;
182 for (let index = 0; index < 12; index += 1) {
183 const amount = (lowerAmount + upperAmount) / 2;
184 const candidate: readonly [number, number, number] = [
185 Math.round(accentChannels[0] + (target[0] - accentChannels[0]) * amount),
186 Math.round(accentChannels[1] + (target[1] - accentChannels[1]) * amount),
187 Math.round(accentChannels[2] + (target[2] - accentChannels[2]) * amount),
188 ];
189 if (contrastRatio(candidate, surfaceChannels) >= 4.5) {
190 readable = candidate;
191 upperAmount = amount;
192 } else {
193 lowerAmount = amount;
194 }
195 }
196 return `#${readable.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
197}
198
199export function themeColorWithAlpha(color: string, alpha: number): string {
200 const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color);
201 if (hex) {
202 return `rgba(${Number.parseInt(hex[1], 16)}, ${Number.parseInt(hex[2], 16)}, ${Number.parseInt(hex[3], 16)}, ${alpha})`;
203 }
204 const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/.exec(color);
205 return rgb ? `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})` : color;
206}
207
208export function createMobileThemeVariables(
209 colors: ThemeColors,
210 appearance: MobileThemeAppearance,
211): MobileThemeVariables {
212 const c = nativeColors(colors);
213 return {
214 "--color-screen": c.canvas,
215 "--color-sheet": withAlpha(c.chrome, 0.98),
216 "--color-sheet-solid": c.chrome,
217 "--color-card": c.surfaceRaised,
218 "--color-card-alt": c.surface,
219 "--color-card-translucent": withAlpha(c.surfaceRaised, 0.8),
220 "--color-foreground": c.text,
221 "--color-foreground-secondary": c.textMuted,
222 "--color-foreground-muted": c.mutedForeground,
223 "--color-foreground-tertiary": c.secondaryLabel,
224 "--color-border": c.border,
225 "--color-border-subtle": withAlpha(c.border, 0.7),
226 "--color-separator": withAlpha(c.border, 0.55),
227 "--color-subtle": c.muted,
228 "--color-subtle-strong": c.secondary,
229 "--color-inline-skill-background": c.accentSurface,
230 "--color-inline-skill-border": withAlpha(c.accent, 0.42),
231 "--color-inline-skill-foreground": c.accentSurfaceForeground,
232 "--color-primary": c.accent,
233 "--color-primary-foreground": c.accentForeground,
234 "--color-primary-shadow": "#000000",
235 "--color-secondary": c.secondary,
236 "--color-secondary-foreground": c.secondaryForeground,
237 "--color-secondary-border": c.border,
238 "--color-switch-active-track": c.accent,
239 "--color-switch-active-thumb": c.accentForeground,
240 "--color-switch-inactive-track": c.secondary,
241 "--color-switch-inactive-thumb": c.mutedForeground,
242 "--color-danger": c.errorSurface,
243 "--color-danger-border": withAlpha(c.error, 0.32),
244 "--color-danger-foreground": c.errorForeground,
245 "--color-input": c.surfaceRaised,
246 "--color-input-border": c.input,
247 "--color-sidebar-search": c.sidebarControlSurface,
248 "--color-placeholder": c.placeholder,
249 "--color-icon": c.text,
250 "--color-icon-muted": c.iconMuted,
251 "--color-icon-subtle": c.secondaryLabel,
252 "--color-header": withAlpha(c.toolbar, 0.97),
253 "--color-header-border": c.toolbarBorder,
254 "--color-glass-surface": withAlpha(c.surfaceOverlay, 0.74),
255 "--color-glass-tint": withAlpha(c.surfaceOverlay, 0.22),
256 "--color-status-bar": c.canvas,
257 "--color-md-body": c.text,
258 "--color-md-strong": c.toolbarForeground,
259 "--color-md-link": c.accent,
260 "--color-md-blockquote-border": c.border,
261 "--color-md-blockquote-bg": c.muted,
262 "--color-md-code-bg": c.codeBackground,
263 "--color-md-code-text": c.codeForeground,
264 "--color-md-user-code-bg": withAlpha(c.messageForeground, 0.18),
265 "--color-md-user-code-text": c.messageForeground,
266 "--color-md-user-fence-bg": withAlpha("#000000", appearance === "dark" ? 0.28 : 0.16),
267 "--color-md-user-fence-text": c.messageForeground,
268 "--color-md-hr": c.border,
269 "--color-user-bubble": c.messageSurface,
270 "--color-user-bubble-foreground": c.messageForeground,
271 "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78),
272 "--color-user-bubble-skill-foreground": readableMessageAccent(
273 c.messageAction,
274 c.messageSurface,
275 ),
276 "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22),
277 "--color-drawer": withAlpha(c.sidebar, 0.99),
278 "--color-drawer-shadow": withAlpha("#000000", appearance === "dark" ? 0.32 : 0.12),
279 "--color-dot-separator": withAlpha(c.textMuted, 0.35),
280 "--color-wordmark": c.text,
281 "--color-chevron": withAlpha(c.textMuted, 0.42),
282 };
283}
284
285export function getMobileThemeVariables(
286 themeId: MobileThemeId,
287 appearance: MobileThemeAppearance,
288 overrides: Partial<MobileThemeVariables> | null = null,
289): MobileThemeVariables {
290 const baseVariables = (() => {
291 if (themeId === DEFAULT_MOBILE_THEME_ID) return DEFAULT_MOBILE_THEME_VARIABLES[appearance];
292 const theme =
293 BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
294 const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
295 return createMobileThemeVariables(colors, appearance);
296 })();
297
298 // The complete base record guarantees that optional overrides cannot leave a token undefined.
299 return overrides ? ({ ...baseVariables, ...overrides } as MobileThemeVariables) : baseVariables;
300}
301
302export function getMobileThemePreviewColors(
303 themeId: MobileThemeId,
304 appearance: MobileThemeAppearance,
305): ThemePreviewColors {
306 if (themeId === DEFAULT_MOBILE_THEME_ID) return STANDARD_THEME_PREVIEW_COLORS[appearance];
307 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
308 const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
309 return {
310 canvas: themeColorToNativeColor(colors.canvas),
311 accent: themeColorToNativeColor(colors.accent),
312 messageAction: themeColorToNativeColor(colors.messageAction),
313 };
314}
apps/mobile/src/lib/storage.test.ts+19
...@@ -177,6 +177,25 @@ describe("mobile connection storage", () => {...@@ -177,6 +177,25 @@ describe("mobile connection storage", () => {
177 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });177 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
178 });178 });
179179
180 it("persists independent light and dark theme choices", async () => {
181 mocks.setPreferencesJson(
182 JSON.stringify({
183 themeId: "grove",
184 lightThemeId: "iris",
185 darkThemeId: "ocean",
186 themeMode: "system",
187 }),
188 10,
189 );
190
191 await expect(loadPreferences()).resolves.toEqual({
192 themeId: "grove",
193 lightThemeId: "iris",
194 darkThemeId: "ocean",
195 themeMode: "system",
196 });
197 });
198
180 it("falls back to secure storage when SQLite cannot save preferences", async () => {199 it("falls back to secure storage when SQLite cannot save preferences", async () => {
181 mocks.setDatabaseFailures(true, true);200 mocks.setDatabaseFailures(true, true);
182 await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });201 await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });
apps/mobile/src/lib/useMobileNavigationTheme.ts created+22
...@@ -0,0 +1,22 @@
1import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native";
2import { useMemo } from "react";
3
4import type { MobileThemeAppearance } from "./mobileTheme";
5import { useThemeColor } from "./useThemeColor";
6
7export function useMobileNavigationTheme(appearance: MobileThemeAppearance): Theme {
8 const primary = String(useThemeColor("--color-primary"));
9 const background = String(useThemeColor("--color-screen"));
10 const card = String(useThemeColor("--color-sheet-solid"));
11 const text = String(useThemeColor("--color-foreground"));
12 const border = String(useThemeColor("--color-header-border"));
13 const notification = String(useThemeColor("--color-danger-foreground"));
14
15 return useMemo(() => {
16 const base = appearance === "dark" ? DarkTheme : DefaultTheme;
17 return {
18 ...base,
19 colors: { ...base.colors, primary, background, card, text, border, notification },
20 };
21 }, [appearance, background, border, card, notification, primary, text]);
22}
apps/mobile/src/native/sheet-surface.ts+2-23
...@@ -1,28 +1,7 @@...@@ -1,28 +1,7 @@
1import { DynamicColorIOS, Platform, type ColorValue, type ViewStyle } from "react-native";
2
3/**1/**
4 * One opaque surface for content rendered inside a native form sheet.2 * Form sheets inherit the live React Navigation palette supplied by App. Each
5 *3 * presented route paints its content with bg-sheet, including nested pushes.
6 * UIKit owns the outer sheet material and rounded corners. The presented route
7 * owns this surface so nested navigators never expose a differently colored
8 * native container while their screens move.
9 */
10export const NATIVE_SHEET_SURFACE_COLOR: ColorValue | undefined =
11 Platform.OS === "ios" ? DynamicColorIOS({ light: "#f2f2f7", dark: "#0e0e0e" }) : undefined;
12
13export const NATIVE_SHEET_SURFACE_CONTENT_STYLE: ViewStyle | undefined =
14 NATIVE_SHEET_SURFACE_COLOR === undefined
15 ? undefined
16 : { backgroundColor: NATIVE_SHEET_SURFACE_COLOR };
17
18/**
19 * Paint the adaptive background on the presented screen itself. Nested stacks
20 * can stay transparent over this single surface, so a push never exposes an
21 * unpainted form-sheet host behind the moving child view controllers.
22 */4 */
23export const FORM_SHEET_PRESENTATION_OPTIONS = {5export const FORM_SHEET_PRESENTATION_OPTIONS = {
24 presentation: "formSheet" as const,6 presentation: "formSheet" as const,
25 ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined
26 ? null
27 : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }),
28};7};
apps/mobile/src/persistence/mobile-preferences.ts+34
...@@ -6,6 +6,7 @@ import * as Ref from "effect/Ref";...@@ -6,6 +6,7 @@ import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";7import * as Semaphore from "effect/Semaphore";
8import type { SidebarProjectGroupingMode } from "@t3tools/contracts";8import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
9import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
910
10import * as MobileDatabase from "./mobile-database";11import * as MobileDatabase from "./mobile-database";
11import * as MobileSecureStorage from "./mobile-secure-storage";12import * as MobileSecureStorage from "./mobile-secure-storage";
...@@ -16,6 +17,10 @@ const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";...@@ -16,6 +17,10 @@ const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";
1617
17export interface Preferences {18export interface Preferences {
18 readonly liveActivitiesEnabled?: boolean;19 readonly liveActivitiesEnabled?: boolean;
20 readonly themeId?: MobileThemeId;
21 readonly lightThemeId?: MobileThemeId;
22 readonly darkThemeId?: MobileThemeId;
23 readonly themeMode?: MobileThemeMode;
19 readonly baseFontSize?: number;24 readonly baseFontSize?: number;
20 readonly terminalFontSize?: number | null;25 readonly terminalFontSize?: number | null;
21 readonly markdownFontSize?: number;26 readonly markdownFontSize?: number;
...@@ -79,6 +84,10 @@ export class MobilePreferencesStore extends Context.Service<...@@ -79,6 +84,10 @@ export class MobilePreferencesStore extends Context.Service<
79function sanitizePreferences(parsed: Preferences): Preferences {84function sanitizePreferences(parsed: Preferences): Preferences {
80 const preferences: {85 const preferences: {
81 liveActivitiesEnabled?: boolean;86 liveActivitiesEnabled?: boolean;
87 themeId?: MobileThemeId;
88 lightThemeId?: MobileThemeId;
89 darkThemeId?: MobileThemeId;
90 themeMode?: MobileThemeMode;
82 baseFontSize?: number;91 baseFontSize?: number;
83 terminalFontSize?: number | null;92 terminalFontSize?: number | null;
84 markdownFontSize?: number;93 markdownFontSize?: number;
...@@ -96,6 +105,31 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -96,6 +105,31 @@ function sanitizePreferences(parsed: Preferences): Preferences {
96 if (typeof parsed.liveActivitiesEnabled === "boolean") {105 if (typeof parsed.liveActivitiesEnabled === "boolean") {
97 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;106 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
98 }107 }
108 if (
109 typeof parsed.themeId === "string" &&
110 (MOBILE_THEME_IDS as readonly string[]).includes(parsed.themeId)
111 ) {
112 preferences.themeId = parsed.themeId as MobileThemeId;
113 }
114 if (
115 typeof parsed.lightThemeId === "string" &&
116 (MOBILE_THEME_IDS as readonly string[]).includes(parsed.lightThemeId)
117 ) {
118 preferences.lightThemeId = parsed.lightThemeId as MobileThemeId;
119 }
120 if (
121 typeof parsed.darkThemeId === "string" &&
122 (MOBILE_THEME_IDS as readonly string[]).includes(parsed.darkThemeId)
123 ) {
124 preferences.darkThemeId = parsed.darkThemeId as MobileThemeId;
125 }
126 if (
127 parsed.themeMode === "system" ||
128 parsed.themeMode === "light" ||
129 parsed.themeMode === "dark"
130 ) {
131 preferences.themeMode = parsed.themeMode;
132 }
99 if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;133 if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;
100 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {134 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
101 preferences.terminalFontSize = parsed.terminalFontSize;135 preferences.terminalFontSize = parsed.terminalFontSize;
apps/web/src/components/settings/ThemePreviewCircles.tsx+18-17
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { MoonIcon, SunIcon } from "lucide-react";1import { MoonIcon, SunIcon } from "lucide-react";
2import type { CSSProperties } from "react";2import type { CSSProperties } from "react";
3import {
4 STANDARD_THEME_PREVIEW_COLORS as SHARED_STANDARD_THEME_PREVIEW_COLORS,
5 THEME_PREVIEW_RENDER_SPECS,
6} from "@t3tools/shared/themePreview";
3import { cn } from "../../lib/utils";7import { cn } from "../../lib/utils";
4import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";8import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5import {9import {
...@@ -37,21 +41,17 @@ const STANDARD_THEME_PREVIEW_COLORS: Record<...@@ -37,21 +41,17 @@ const STANDARD_THEME_PREVIEW_COLORS: Record<
37> = {41> = {
38 light: {42 light: {
39 sidebar: "#fafafa",43 sidebar: "#fafafa",
40 canvas: "#fcfcfc",
41 surface: "#ffffff",44 surface: "#ffffff",
42 accentSurface: "#f4f4f5",45 accentSurface: "#f4f4f5",
43 accent: "#f4f4f5",
44 messageSurface: "#e4e4e7",46 messageSurface: "#e4e4e7",
45 messageAction: "#4f46e5",47 ...SHARED_STANDARD_THEME_PREVIEW_COLORS.light,
46 },48 },
47 dark: {49 dark: {
48 sidebar: "#0f0f10",50 sidebar: "#0f0f10",
49 canvas: "#0a0a0a",
50 surface: "#121212",51 surface: "#121212",
51 accentSurface: "#27272a",52 accentSurface: "#27272a",
52 accent: "#1c1c1f",
53 messageSurface: "#27272a",53 messageSurface: "#27272a",
54 messageAction: "#8b9cff",54 ...SHARED_STANDARD_THEME_PREVIEW_COLORS.dark,
55 },55 },
56};56};
5757
...@@ -102,23 +102,20 @@ function getThemePreviewStyle(...@@ -102,23 +102,20 @@ function getThemePreviewStyle(
102 colors: ThemeCardPreviewColors,102 colors: ThemeCardPreviewColors,
103 mode: ThemeAppearance,103 mode: ThemeAppearance,
104): CSSProperties {104): CSSProperties {
105 const isDark = mode === "dark";105 const spec = THEME_PREVIEW_RENDER_SPECS[mode];
106 // The canvas carries the ball's light/dark identity, so it stays dominant:106 // The canvas carries the ball's light/dark identity, so it stays dominant:
107 // a near-true base with a contained accent glow, instead of an accent wash107 // a near-true base with a contained accent glow, instead of an accent wash
108 // that makes both modes read alike.108 // that makes both modes read alike.
109 const modeBase = isDark109 const modeBase = `color-mix(in oklab, ${colors.canvas} ${spec.baseWeight * 100}%, ${spec.baseTarget})`;
110 ? `color-mix(in oklab, ${colors.canvas} 80%, #09090b)`110 const accentPosition = `${spec.accent.center[0] * 100}% ${spec.accent.center[1] * 100}%`;
111 : `color-mix(in oklab, ${colors.canvas} 80%, #ffffff)`;111 const actionPosition = `${spec.action.center[0] * 100}% ${spec.action.center[1] * 100}%`;
112 const accentPosition = isDark ? "28% 78%" : "72% 22%";
113 const actionPosition = isDark ? "82% 18%" : "18% 82%";
114 const accentFade = isDark ? 62 : 72;
115 return {112 return {
116 backgroundColor: modeBase,113 backgroundColor: modeBase,
117 backgroundImage: [114 backgroundImage: [
118 `radial-gradient(circle at ${accentPosition} in oklab, ${colors.accent} 0%, color-mix(in oklab, ${colors.accent} ${accentFade}%, transparent) 28%, transparent 58%)`,115 `radial-gradient(circle at ${accentPosition} in oklab, ${colors.accent} 0%, color-mix(in oklab, ${colors.accent} ${spec.accent.middleOpacity * 100}%, transparent) ${spec.accent.middleOffset * 100}%, transparent ${spec.accent.endOffset * 100}%)`,
119 // The action color is a soft tint from the opposite corner, not a second116 // The action color is a soft tint from the opposite corner, not a second
120 // light source — two bright hotspots read as headlights.117 // light source — two bright hotspots read as headlights.
121 `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} 45%, transparent) 0%, transparent 55%)`,118 `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} ${spec.action.startOpacity * 100}%, transparent) 0%, transparent ${spec.action.endOffset * 100}%)`,
122 ].join(", "),119 ].join(", "),
123 };120 };
124}121}
...@@ -145,8 +142,12 @@ export function ThemePreviewCircle({...@@ -145,8 +142,12 @@ export function ThemePreviewCircle({
145 style={{ boxShadow: themePreviewEdgeShadow(mode) }}142 style={{ boxShadow: themePreviewEdgeShadow(mode) }}
146 >143 >
147 <span144 <span
148 className="absolute inset-0 scale-110 rounded-full blur-[3px]"145 className="absolute inset-0 rounded-full"
149 style={getThemePreviewStyle(colors, mode)}146 style={{
147 ...getThemePreviewStyle(colors, mode),
148 filter: `blur(${THEME_PREVIEW_RENDER_SPECS[mode].blurAt56Px}px)`,
149 transform: `scale(${THEME_PREVIEW_RENDER_SPECS[mode].scale})`,
150 }}
150 />151 />
151 </span>152 </span>
152 );153 );
apps/web/src/themePalette.test.ts+11
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
23
3import {4import {
4 applyThemeColorPreview,5 applyThemeColorPreview,
...@@ -78,6 +79,16 @@ function contrastRatio(first: string, second: string): number {...@@ -78,6 +79,16 @@ function contrastRatio(first: string, second: string): number {
78}79}
7980
80describe("theme files", () => {81describe("theme files", () => {
82 it("keeps every built-in palette value in canonical OKLCH form", () => {
83 for (const theme of BUILT_IN_THEMES) {
84 for (const colors of [theme.colors, ...Object.values(theme.variants ?? {})]) {
85 for (const value of Object.values(colors)) {
86 expect(toCanonicalThemeColor(value)).toBe(value);
87 }
88 }
89 }
90 });
91
81 it("derives a readable palette from extreme simple-editor colors", () => {92 it("derives a readable palette from extreme simple-editor colors", () => {
82 const light = createManagedThemeColors("light", "#111827", "#ffff00");93 const light = createManagedThemeColors("light", "#111827", "#ffff00");
83 const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00");94 const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00");
apps/web/src/themePalette.ts+18-335
...@@ -1,6 +1,23 @@...@@ -1,6 +1,23 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import "culori/css";2import "culori/css";
3import { converter, parse } from "culori/fn";3import { converter, parse } from "culori/fn";
4import {
5 BUILT_IN_THEMES,
6 EMBER_THEME,
7 GROVE_THEME,
8 IRIS_THEME,
9 OCEAN_THEME,
10 T3_CHAT_THEME,
11 THEME_COLOR_ROLES,
12 type ThemeAppearance,
13 type ThemeColorRole,
14 type ThemeColors,
15 type ThemeDefinition,
16 type ThemeVariants,
17} from "@t3tools/shared/themePalettes";
18
19export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES };
20export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants };
421
5export const T3_CHAT_THEME_ID = "t3-chat" as const;22export const T3_CHAT_THEME_ID = "t3-chat" as const;
6export const T3_CHAT_THEME_LABEL = "T3 Chat";23export const T3_CHAT_THEME_LABEL = "T3 Chat";
...@@ -23,90 +40,11 @@ const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark";...@@ -23,90 +40,11 @@ const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark";
23export const ThemePreference = Schema.String;40export const ThemePreference = Schema.String;
24export type ThemePreference = typeof ThemePreference.Type;41export type ThemePreference = typeof ThemePreference.Type;
2542
26export const THEME_COLOR_ROLES = [
27 "canvas",
28 "chrome",
29 "toolbar",
30 "toolbarForeground",
31 "toolbarBorder",
32 "toolbarControl",
33 "toolbarControlForeground",
34 "toolbarControlHover",
35 "surface",
36 "surfaceRaised",
37 "surfaceOverlay",
38 "text",
39 "textMuted",
40 "border",
41 "input",
42 "focus",
43 "accent",
44 "accentForeground",
45 "secondary",
46 "secondaryForeground",
47 "muted",
48 "mutedForeground",
49 "placeholder",
50 "secondaryLabel",
51 "iconMuted",
52 "error",
53 "errorForeground",
54 "errorSurface",
55 "warning",
56 "warningForeground",
57 "warningSurface",
58 "update",
59 "updateForeground",
60 "updateSurface",
61 "accentSurface",
62 "accentSurfaceForeground",
63 "messageSurface",
64 "messageForeground",
65 "messageAction",
66 "messageActionForeground",
67 "messageActionHover",
68 "codeBackground",
69 "codeForeground",
70 "sidebar",
71 "sidebarForeground",
72 "sidebarMutedForeground",
73 "sidebarControlSurface",
74 "sidebarRowHover",
75 "sidebarRowActive",
76 "sidebarRowSelected",
77 "sidebarBorder",
78 "terminalBackground",
79 "terminalForeground",
80 "terminalCursor",
81 "terminalSelection",
82 "terminalScrollbar",
83 "terminalScrollbarHover",
84] as const;
85
86export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number];
87const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES);43const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES);
88export type ThemeAppearance = "light" | "dark";
89
90export type ThemeColors = Readonly<Record<ThemeColorRole, string>>;
91export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>;44export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>;
92export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>;
93export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>;45export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>;
94export type ThemePreferenceMode = ThemeAppearance | "system";46export type ThemePreferenceMode = ThemeAppearance | "system";
95export type ThemeCollection = Readonly<{ id: string; label: string }>;47export type ThemeCollection = Readonly<{ id: string; label: string }>;
96export type ThemeDefinition = Readonly<{
97 id: string;
98 label: string;
99 appearance: ThemeAppearance;
100 colors: ThemeColors;
101 variants?: ThemeVariants;
102 /** Groups related imported variants into one library card. */
103 collection?: ThemeCollection;
104 /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */
105 sidebarArtwork?: boolean;
106 /** True when the palette was generated by the guided editor from its
107 * canvas and accent; such themes reopen in guided mode. */
108 managed?: boolean;
109}>;
110export type ThemeFile = Readonly<{48export type ThemeFile = Readonly<{
111 version: typeof THEME_FILE_VERSION;49 version: typeof THEME_FILE_VERSION;
112 id: string;50 id: string;
...@@ -366,155 +304,6 @@ function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null {...@@ -366,155 +304,6 @@ function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null {
366 return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null;304 return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null;
367}305}
368306
369/**
370 * Maintainer palettes use product color roles rather than Tailwind or component
371 * names so the same definitions can feed other clients and native surfaces.
372 */
373// Measured from the live t3.chat default theme. Translucent chat surfaces are
374// flattened over --chat-background so this opaque palette reproduces the
375// pixels users see after T3 Chat's blur and noise layers are composited.
376// Foreground pairs deviate where necessary to keep normal text at WCAG AA.
377const T3_CHAT_LIGHT_COLORS: ThemeColors = {
378 canvas: "#fdf7fd",
379 // T3 Code's workspace header belongs to the chat panel, so keep it seamless
380 // with the light chat canvas rather than mapping it to T3 Chat's outer shell.
381 chrome: "#fdf7fd",
382 toolbar: "#fdf7fd",
383 toolbarForeground: "#501854",
384 toolbarBorder: "#efbdeb",
385 // T3 Chat's light chrome controls sit on its pale gradient-noise surface,
386 // not the substantially darker solid accent token.
387 toolbarControl: "#f3e6f5",
388 toolbarControlForeground: "#501854",
389 toolbarControlHover: "#eccfe3",
390 surface: "#faf3fb",
391 surfaceRaised: "#fdfafd",
392 surfaceOverlay: "#ffffff",
393 text: "#501854",
394 textMuted: "#ac1668",
395 border: "#eee1ed",
396 input: "#e7c1dc",
397 focus: "#db2777",
398 accent: "#db2777",
399 accentForeground: "#ffffff",
400 secondary: "#f1c4e6",
401 secondaryForeground: "#77347c",
402 muted: "#eaa7cb",
403 mutedForeground: "#8d1255",
404 placeholder: "#8b5f90",
405 secondaryLabel: "#ac1668",
406 iconMuted: "#ac1668",
407 error: "#f7086c",
408 errorForeground: "#9d174d",
409 errorSurface: "#fde4f1",
410 warning: "#f59e0b",
411 warningForeground: "#b05109",
412 warningSurface: "#fcf0ea",
413 update: "#db2777",
414 updateForeground: "#ac1668",
415 updateSurface: "#fadfef",
416 accentSurface: "#f3e6f5",
417 accentSurfaceForeground: "#454554",
418 messageSurface: "#f7def2",
419 messageForeground: "#492c61",
420 messageAction: "#db2777",
421 messageActionForeground: "#ffffff",
422 messageActionHover: "#c12269",
423 // T3 Chat uses a light lavender code surface in light mode. Keeping the
424 // dark plum pair here also leaked the dark palette into T3 Code's diffs.
425 codeBackground: "#f5ecf9",
426 codeForeground: "#673c8b",
427 // The live sidebar is transparent over T3 Chat's outer shell. Use that
428 // rendered shell color rather than its unused, darker sidebar token.
429 sidebar: "#f2e1f4",
430 sidebarForeground: "#454554",
431 sidebarMutedForeground: "#ac1668",
432 sidebarControlSurface: "#f8f8f7",
433 sidebarRowHover: "#f8f8f7",
434 sidebarRowActive: "#f8f8f7",
435 sidebarRowSelected: "#f8f8f7",
436 sidebarBorder: "#eceae9",
437 terminalBackground: "#fdf7fd",
438 terminalForeground: "#501854",
439 terminalCursor: "#db2777",
440 terminalSelection: "#f1c4e6",
441 terminalScrollbar: "#e7c1dc",
442 terminalScrollbarHover: "#eaa7cb",
443};
444
445const T3_CHAT_DARK_COLORS: ThemeColors = {
446 canvas: "#1f1a24",
447 // T3 Code's workspace header belongs to the chat panel, so keep it seamless
448 // with the canvas rather than mapping it to T3 Chat's outer shell.
449 chrome: "#1f1a24",
450 toolbar: "#1f1a24",
451 toolbarForeground: "#f9f8fb",
452 toolbarBorder: "#27242c",
453 toolbarControl: "#362d3d",
454 toolbarControlForeground: "#d4c7e1",
455 toolbarControlHover: "#463753",
456 // Cards and panels stay in T3 Chat's plum surface family. Near-black here
457 // made the right-panel surface picker look unrelated to the chat canvas.
458 surface: "#29232d",
459 // Pre-composited for the composer's 80% glass layer; this resolves to the
460 // measured #29232d input fill over the canvas.
461 surfaceRaised: "#2c2631",
462 surfaceOverlay: "#100a0e",
463 text: "#f9f8fb",
464 textMuted: "#e7d0dd",
465 border: "#27242c",
466 input: "#302029",
467 focus: "#db2777",
468 accent: "#a3004c",
469 accentForeground: "#fbd0e8",
470 secondary: "#362d3d",
471 secondaryForeground: "#d4c7e1",
472 muted: "#423a45",
473 mutedForeground: "#e7d0dd",
474 placeholder: "#968d9f",
475 secondaryLabel: "#e7d0dd",
476 iconMuted: "#d4c7e1",
477 error: "#9d174d",
478 errorForeground: "#fbd0e8",
479 errorSurface: "#331a2b",
480 warning: "#f59e0b",
481 warningForeground: "#fbbf24",
482 warningSurface: "#412f20",
483 update: "#a3004c",
484 updateForeground: "#fbd0e8",
485 updateSurface: "#37152b",
486 accentSurface: "#463753",
487 accentSurfaceForeground: "#f8f1f5",
488 messageSurface: "#2b2431",
489 messageForeground: "#f2ebfa",
490 messageAction: "#a3004c",
491 messageActionForeground: "#fbd0e8",
492 messageActionHover: "#a2004c",
493 // Diffs and file previews are full workspace surfaces in T3 Code. Keep them
494 // continuous with the themed canvas instead of dropping to near-black.
495 codeBackground: "#1f1a24",
496 codeForeground: "#d8c3ef",
497 // The live sidebar starts from #131314, then gains its hue from a pink
498 // gradient/noise stack. This pre-grain base lands on the same #1a131a
499 // visible shell color after our surface-grain layer is composited.
500 sidebar: "#171018",
501 sidebarForeground: "#f4f4f5",
502 sidebarMutedForeground: "#e7d0dd",
503 sidebarControlSurface: "#261922",
504 sidebarRowHover: "#261922",
505 sidebarRowActive: "#261922",
506 sidebarRowSelected: "#261922",
507 // T3 Chat draws the chat panel edge in this muted pink. The resize rail uses
508 // the same role on hover, so it stays pink instead of falling back to black.
509 sidebarBorder: "#322028",
510 terminalBackground: "#1f1a24",
511 terminalForeground: "#f9f8fb",
512 terminalCursor: "#db2777",
513 terminalSelection: "#362d3d",
514 terminalScrollbar: "#302029",
515 terminalScrollbarHover: "#423a45",
516};
517
518/**307/**
519 * The palette T3 Code wears with no theme installed, captured from the app's308 * The palette T3 Code wears with no theme installed, captured from the app's
520 * stock tokens (index.css) so a draft seeded from the default look paints the309 * stock tokens (index.css) so a draft seeded from the default look paints the
...@@ -1431,43 +1220,11 @@ export function createManagedThemeColors(...@@ -1431,43 +1220,11 @@ export function createManagedThemeColors(
1431 };1220 };
1432}1221}
14331222
1434export const T3_CHAT_THEME: ThemeDefinition = {
1435 id: T3_CHAT_THEME_ID,
1436 label: T3_CHAT_THEME_LABEL,
1437 appearance: "light",
1438 colors: decodeThemeColors(T3_CHAT_LIGHT_COLORS),
1439 variants: {
1440 dark: decodeThemeColors(T3_CHAT_DARK_COLORS),
1441 },
1442 sidebarArtwork: true,
1443};
1444
1445/** Theme-file defaults follow the flagship palette for the requested mode. */1223/** Theme-file defaults follow the flagship palette for the requested mode. */
1446export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors {1224export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors {
1447 return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors;1225 return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors;
1448}1226}
14491227
1450/**
1451 * A companion action color in the T3 Chat mold. This gives send buttons,
1452 * status pills, and theme previews a second voice; foreground and hover follow
1453 * the same rules as the managed generator.
1454 */
1455function themeActionColors(
1456 action: string,
1457): Pick<ThemeColors, "messageAction" | "messageActionForeground" | "messageActionHover"> {
1458 const rgb = parseThemeRgbColor(action, THEME_DARK_FOREGROUND);
1459 const foreground = readableThemeForeground(rgb);
1460 const towardOpposite =
1461 foreground === THEME_LIGHT_FOREGROUND || foreground === THEME_WHITE_FOREGROUND
1462 ? THEME_BLACK_FOREGROUND
1463 : THEME_WHITE_FOREGROUND;
1464 return {
1465 messageAction: toCanonicalThemeColor(action) ?? themeRgbToThemeColor(rgb),
1466 messageActionForeground: themeRgbToThemeColor(foreground),
1467 messageActionHover: themeRgbToThemeColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)),
1468 };
1469}
1470
1471/**1228/**
1472 * Update one Advanced-editor color family without normalizing the rest of an1229 * Update one Advanced-editor color family without normalizing the rest of an
1473 * imported or hand-tuned palette. The editor exposes a representative role1230 * imported or hand-tuned palette. The editor exposes a representative role
...@@ -1660,81 +1417,7 @@ export function updateThemeColorFamily(...@@ -1660,81 +1417,7 @@ export function updateThemeColorFamily(
1660 }1417 }
1661}1418}
16621419
1663export const GROVE_THEME: ThemeDefinition = {1420const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = BUILT_IN_THEMES;
1664 id: GROVE_THEME_ID,
1665 label: GROVE_THEME_LABEL,
1666 appearance: "light",
1667 colors: {
1668 ...createManagedThemeColors("light", "#f2f8f4", "#19734a"),
1669 ...themeActionColors("#8f6410"),
1670 },
1671 variants: {
1672 dark: {
1673 ...createManagedThemeColors("dark", "#1d2b24", "#69d69a"),
1674 ...themeActionColors("#e3b34e"),
1675 },
1676 },
1677 sidebarArtwork: true,
1678};
1679
1680export const OCEAN_THEME: ThemeDefinition = {
1681 id: OCEAN_THEME_ID,
1682 label: OCEAN_THEME_LABEL,
1683 appearance: "light",
1684 colors: {
1685 ...createManagedThemeColors("light", "#f2f7fb", "#2878b8"),
1686 ...themeActionColors("#0a6f75"),
1687 },
1688 variants: {
1689 dark: {
1690 ...createManagedThemeColors("dark", "#1b2938", "#70b9ee"),
1691 ...themeActionColors("#5bd0d6"),
1692 },
1693 },
1694 sidebarArtwork: true,
1695};
1696
1697export const EMBER_THEME: ThemeDefinition = {
1698 id: EMBER_THEME_ID,
1699 label: EMBER_THEME_LABEL,
1700 appearance: "light",
1701 colors: {
1702 ...createManagedThemeColors("light", "#fff6ef", "#c4602f"),
1703 ...themeActionColors("#b23535"),
1704 },
1705 variants: {
1706 dark: {
1707 ...createManagedThemeColors("dark", "#30231e", "#f39a62"),
1708 ...themeActionColors("#f78a7a"),
1709 },
1710 },
1711 sidebarArtwork: true,
1712};
1713
1714export const IRIS_THEME: ThemeDefinition = {
1715 id: IRIS_THEME_ID,
1716 label: IRIS_THEME_LABEL,
1717 appearance: "light",
1718 colors: {
1719 ...createManagedThemeColors("light", "#f7f4fc", "#7254b9"),
1720 ...themeActionColors("#a82c87"),
1721 },
1722 variants: {
1723 dark: {
1724 ...createManagedThemeColors("dark", "#29243b", "#ad92f5"),
1725 ...themeActionColors("#f099d8"),
1726 },
1727 },
1728 sidebarArtwork: true,
1729};
1730
1731const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = [
1732 T3_CHAT_THEME,
1733 GROVE_THEME,
1734 OCEAN_THEME,
1735 EMBER_THEME,
1736 IRIS_THEME,
1737];
17381421
1739export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null {1422export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null {
1740 const themeId = themeIdFromPreference(theme);1423 const themeId = themeIdFromPreference(theme);
docs/README.md+1
...@@ -8,6 +8,7 @@...@@ -8,6 +8,7 @@
8- [Organizing threads](./user/thread-sidebar.md)8- [Organizing threads](./user/thread-sidebar.md)
9- [Review usage](./user/usage.md)9- [Review usage](./user/usage.md)
10- [Customize a project icon](./user/project-settings.md)10- [Customize a project icon](./user/project-settings.md)
11- [Mobile appearance](./user/mobile-appearance.md)
11- [Remote access](./user/remote-access.md)12- [Remote access](./user/remote-access.md)
12- [Keeping app and server in sync](./user/updating.md)13- [Keeping app and server in sync](./user/updating.md)
13- [Source control integrations](./user/source-control.md)14- [Source control integrations](./user/source-control.md)
docs/operations/mobile-app-store-screenshots.md+43-26
...@@ -30,7 +30,7 @@ The command:...@@ -30,7 +30,7 @@ The command:
304. Starts an isolated Metro server, builds the selected native apps, and boots each device.304. Starts an isolated Metro server, builds the selected native apps, and boots each device.
315. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin.315. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin.
326. Navigates to the real application route for every requested scene.326. Navigates to the real application route for every requested scene.
337. Sets the requested system appearance and normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and337. Sets the requested system appearance and palette, normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and
34 validates dimensions, aspect ratio, file size, and screenshot count before succeeding.34 validates dimensions, aspect ratio, file size, and screenshot count before succeeding.
358. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded358. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded
36 directly to App Store Connect or Google Play Console.36 directly to App Store Connect or Google Play Console.
...@@ -51,49 +51,60 @@ shared across every checkout. The readiness check only verifies that the port is...@@ -51,49 +51,60 @@ shared across every checkout. The readiness check only verifies that the port is
51verify process ownership. Concurrent screenshot harnesses in different worktrees can therefore51verify process ownership. Concurrent screenshot harnesses in different worktrees can therefore
52collide or attach to the wrong Metro process.52collide or attach to the wrong Metro process.
5353
54Every configured device defaults to dark appearance, so plain `pnpm screenshots:mobile` produces54Every configured device defaults to dark appearance and the `t3-code` palette, so plain
5530 dark PNGs. Pass `--appearance light`, `--appearance dark`, or `--appearance both` to override the55`pnpm screenshots:mobile` produces 30 dark PNGs. Pass `--appearance light`, `--appearance dark`, or
56configured appearance; `both` produces 60 PNGs.56`--appearance both` to override the configured appearance; `both` produces 60 PNGs.
57
58Pass `--theme <id>` (repeatable) or `--theme all` to capture the app's other palettes: `t3-code`,
59`t3-chat`, `grove`, `ocean`, `ember`, and `iris`. The runner hands the palette to the app as a launch
60argument, the app applies it to both color schemes, and a scene only reports itself ready once the
61requested palette is active — so a capture can never show the previous theme. `--theme all`
62multiplies the run by six; only the native build is shared.
5763
58The default matrix is:64The default matrix is:
5965
60| Output folder | Capture target | Upload dimensions | Store slot |66| Output folder | Capture target | Upload dimensions | Store slot |
61| ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- |67| ------------------------------------- | ------------------------- | ----------------- | ----------------------------------------- |
62| `apple/iphone-6.9/dark/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch |68| `apple/iphone-6.9/dark/t3-code/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch |
63| `apple/iphone-6.5/dark/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch |69| `apple/iphone-6.5/dark/t3-code/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch |
64| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape |70| `apple/ipad-13/dark/t3-code/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape |
65| `google-play/phone/dark/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 |71| `google-play/phone/dark/t3-code/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 |
66| `google-play/tablet-7/dark/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 |72| `google-play/tablet-7/dark/t3-code/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 |
67| `google-play/tablet-10/dark/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 |73| `google-play/tablet-10/dark/t3-code/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 |
6874
69Each target captures thread, terminal, review, thread list, and environments. Each appearance75Each target captures thread, terminal, review, thread list, and environments. Each palette folder's
70folder's five screenshots satisfy the configured Apple limit of 1–10, Google76five screenshots satisfy the configured Apple limit of 1–10, Google
71phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8.77phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8.
78Every palette gets its own leaf folder so one upload slot never mixes themes and each folder keeps a
79store-legal screenshot count.
7280
73The generated tree is deliberately aligned with the store upload fields:81The generated tree is deliberately aligned with the store upload fields:
7482
75 artifacts/app-store/screenshots/83 artifacts/app-store/screenshots/
76 ├── apple/84 ├── apple/
77 │ ├── iphone-6.9/dark/{thread,terminal,review,threads,environments}.png85 │ ├── iphone-6.9/dark/t3-code/{thread,terminal,review,threads,environments}.png
78 │ ├── iphone-6.5/dark/{thread,terminal,review,threads,environments}.png86 │ ├── iphone-6.5/dark/t3-code/{thread,terminal,review,threads,environments}.png
79 │ └── ipad-13/dark/{thread,terminal,review,threads,environments}.png87 │ └── ipad-13/dark/t3-code/{thread,terminal,review,threads,environments}.png
80 └── google-play/88 └── google-play/
81 ├── phone/dark/{thread,terminal,review,threads,environments}.png89 ├── phone/dark/t3-code/{thread,terminal,review,threads,environments}.png
82 ├── tablet-7/dark/{thread,terminal,review,threads,environments}.png90 ├── tablet-7/dark/t3-code/{thread,terminal,review,threads,environments}.png
83 └── tablet-10/dark/{thread,terminal,review,threads,environments}.png91 └── tablet-10/dark/t3-code/{thread,terminal,review,threads,environments}.png
8492
85A light-only run writes the same tree under `light/`; `--appearance both` writes both appearance93A light-only run writes the same tree under `light/`; `--appearance both` writes both appearance
86folders.94folders, and each requested theme adds a sibling folder next to `t3-code/`.
8795
88Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD96Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD
89names, light/dark appearance, iOS orientation, scenes, output directory, capture delay, Android ABI,97names, light/dark appearance, default palette, iOS orientation, scenes, output directory, capture
90or viewport.98delay, Android ABI, or viewport. The selectable palette ids come from `MOBILE_THEME_IDS` in
99[themePalettes.ts](../../packages/shared/src/themePalettes.ts), so the harness and the app's
100appearance settings can never drift apart.
91101
92## Capture in GitHub Actions102## Capture in GitHub Actions
93103
94Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab, choose `all`, `ios`, or104Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab, choose `all`, `ios`, or
95`android`, and select `light`, `dark`, or `both`. The default dispatch captures both appearances and105`android`, select `light`, `dark`, or `both`, and pick a palette (or `all`, which raises each job's
96runs iOS and Android concurrently: iPhone and iPad capture on a106timeout from 60 to 300 minutes). The default dispatch captures both appearances of the `t3-code`
107palette and runs iOS and Android concurrently: iPhone and iPad capture on a
9712-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a10812-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a
9816-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator.10916-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator.
99110
...@@ -120,6 +131,12 @@ Override the configured appearance or capture both variants:...@@ -120,6 +131,12 @@ Override the configured appearance or capture both variants:
120 pnpm screenshots:mobile --appearance dark131 pnpm screenshots:mobile --appearance dark
121 pnpm screenshots:mobile --appearance both132 pnpm screenshots:mobile --appearance both
122133
134Capture other palettes:
135
136 pnpm screenshots:mobile --device iphone-6.9 --theme ocean
137 pnpm screenshots:mobile --device iphone-6.9 --theme ocean --theme ember
138 pnpm screenshots:mobile --device iphone-6.9 --theme all
139
123Reuse the native build and retain the disposable environment:140Reuse the native build and retain the disposable environment:
124141
125 pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running142 pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running
docs/user/mobile-appearance.md created+15
...@@ -0,0 +1,15 @@
1# Mobile appearance
2
3T3 Code Mobile includes the T3 Code, T3 Chat, Grove, Ocean, Ember, and Iris themes. Each theme has
4light and dark colors that apply throughout the app, including code reviews, file previews, the
5terminal, native headers, and sheets.
6
7To change themes:
8
91. Open **Settings**.
102. Select **Appearance**.
113. Choose a theme.
124. Select **System**, **Light**, or **Dark**.
13
14**System** follows the device appearance automatically. Theme, text, code, and terminal appearance
15preferences are stored on the device.
packages/shared/package.json+8
...@@ -3,6 +3,14 @@...@@ -3,6 +3,14 @@
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 "./themePalettes": {
7 "types": "./src/themePalettes.ts",
8 "import": "./src/themePalettes.ts"
9 },
10 "./themePreview": {
11 "types": "./src/themePreview.ts",
12 "import": "./src/themePreview.ts"
13 },
6 "./projectFavicon": {14 "./projectFavicon": {
7 "types": "./src/projectFavicon.ts",15 "types": "./src/projectFavicon.ts",
8 "import": "./src/projectFavicon.ts"16 "import": "./src/projectFavicon.ts"
packages/shared/src/themePalettes.ts created+748
...@@ -0,0 +1,748 @@
1export const BUILT_IN_THEME_IDS = ["t3-chat", "grove", "ocean", "ember", "iris"] as const;
2
3/** The mobile app's own hand-tuned palette, which is not part of the built-in library. */
4export const MOBILE_DEFAULT_THEME_ID = "t3-code";
5
6/**
7 * Every palette the mobile app can render. Declared here so host-side tooling
8 * (the app-store screenshot harness) can validate a requested theme without
9 * importing React Native application code.
10 */
11export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const;
12
13export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number];
14export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number];
15export type ThemeAppearance = "light" | "dark";
16
17/** Product roles shared by web CSS, React Native tokens, and native surfaces. */
18export const THEME_COLOR_ROLES = [
19 "canvas",
20 "chrome",
21 "toolbar",
22 "toolbarForeground",
23 "toolbarBorder",
24 "toolbarControl",
25 "toolbarControlForeground",
26 "toolbarControlHover",
27 "surface",
28 "surfaceRaised",
29 "surfaceOverlay",
30 "text",
31 "textMuted",
32 "border",
33 "input",
34 "focus",
35 "accent",
36 "accentForeground",
37 "secondary",
38 "secondaryForeground",
39 "muted",
40 "mutedForeground",
41 "placeholder",
42 "secondaryLabel",
43 "iconMuted",
44 "error",
45 "errorForeground",
46 "errorSurface",
47 "warning",
48 "warningForeground",
49 "warningSurface",
50 "update",
51 "updateForeground",
52 "updateSurface",
53 "accentSurface",
54 "accentSurfaceForeground",
55 "messageSurface",
56 "messageForeground",
57 "messageAction",
58 "messageActionForeground",
59 "messageActionHover",
60 "codeBackground",
61 "codeForeground",
62 "sidebar",
63 "sidebarForeground",
64 "sidebarMutedForeground",
65 "sidebarControlSurface",
66 "sidebarRowHover",
67 "sidebarRowActive",
68 "sidebarRowSelected",
69 "sidebarBorder",
70 "terminalBackground",
71 "terminalForeground",
72 "terminalCursor",
73 "terminalSelection",
74 "terminalScrollbar",
75 "terminalScrollbarHover",
76] as const;
77
78export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number];
79export type ThemeColors = Readonly<Record<ThemeColorRole, string>>;
80export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>;
81export type ThemeDefinition = Readonly<{
82 id: string;
83 label: string;
84 appearance: ThemeAppearance;
85 colors: ThemeColors;
86 variants?: ThemeVariants;
87 /** Groups related imported variants into one library card. */
88 collection?: Readonly<{ id: string; label: string }>;
89 /** Allows reviewed built-ins to render product artwork over their sidebar. */
90 sidebarArtwork?: boolean;
91 /** Generated from the guided editor's canvas and accent roles. */
92 managed?: boolean;
93}>;
94
95export const T3_CHAT_THEME: ThemeDefinition = {
96 id: "t3-chat",
97 label: "T3 Chat",
98 appearance: "light",
99 colors: {
100 canvas: "oklch(0.982446 0.010114 325.653)",
101 chrome: "oklch(0.982446 0.010114 325.653)",
102 toolbar: "oklch(0.982446 0.010114 325.653)",
103 toolbarForeground: "oklch(0.325698 0.116116 325.037)",
104 toolbarBorder: "oklch(0.856784 0.082879 328.911)",
105 toolbarControl: "oklch(0.939552 0.024286 321.664)",
106 toolbarControlForeground: "oklch(0.325698 0.116116 325.037)",
107 toolbarControlHover: "oklch(0.884525 0.041658 337.177)",
108 surface: "oklch(0.971835 0.012884 321.894)",
109 surfaceRaised: "oklch(0.988235 0.005049 325.615)",
110 surfaceOverlay: "oklch(1 0 0)",
111 text: "oklch(0.325698 0.116116 325.037)",
112 textMuted: "oklch(0.494754 0.190937 354.544)",
113 border: "oklch(0.923531 0.021247 328.096)",
114 input: "oklch(0.851713 0.055822 336.6)",
115 focus: "oklch(0.591646 0.217985 0.584)",
116 accent: "oklch(0.591646 0.217985 0.584)",
117 accentForeground: "oklch(1 0 0)",
118 secondary: "oklch(0.869588 0.06751 334.899)",
119 secondaryForeground: "oklch(0.444777 0.134061 324.799)",
120 muted: "oklch(0.802407 0.090963 345.892)",
121 mutedForeground: "oklch(0.428932 0.163929 354.332)",
122 placeholder: "oklch(0.549927 0.090215 323.149)",
123 secondaryLabel: "oklch(0.494754 0.190937 354.544)",
124 iconMuted: "oklch(0.494754 0.190937 354.544)",
125 error: "oklch(0.627117 0.248974 7.734)",
126 errorForeground: "oklch(0.458704 0.169677 3.815)",
127 errorSurface: "oklch(0.942787 0.032076 344.963)",
128 warning: "oklch(0.76859 0.164659 70.08)",
129 warningForeground: "oklch(0.54612 0.143036 48.949)",
130 warningSurface: "oklch(0.962901 0.015297 48.56)",
131 update: "oklch(0.591646 0.217985 0.584)",
132 updateForeground: "oklch(0.494754 0.190937 354.544)",
133 updateSurface: "oklch(0.930264 0.036194 341.45)",
134 accentSurface: "oklch(0.939552 0.024286 321.664)",
135 accentSurfaceForeground: "oklch(0.396296 0.025134 285.196)",
136 messageSurface: "oklch(0.926746 0.037898 332.6)",
137 messageForeground: "oklch(0.354591 0.093575 307.568)",
138 messageAction: "oklch(0.591646 0.217985 0.584)",
139 messageActionForeground: "oklch(1 0 0)",
140 messageActionHover: "oklch(0.539042 0.197866 0.305)",
141 codeBackground: "oklch(0.953855 0.019695 315.668)",
142 codeForeground: "oklch(0.445128 0.13005 307.026)",
143 sidebar: "oklch(0.928886 0.031178 322.592)",
144 sidebarForeground: "oklch(0.396296 0.025134 285.196)",
145 sidebarMutedForeground: "oklch(0.494754 0.190937 354.544)",
146 sidebarControlSurface: "oklch(0.978851 0.001321 106.424)",
147 sidebarRowHover: "oklch(0.978851 0.001321 106.424)",
148 sidebarRowActive: "oklch(0.978851 0.001321 106.424)",
149 sidebarRowSelected: "oklch(0.978851 0.001321 106.424)",
150 sidebarBorder: "oklch(0.938313 0.002552 48.717)",
151 terminalBackground: "oklch(0.982446 0.010114 325.653)",
152 terminalForeground: "oklch(0.325698 0.116116 325.037)",
153 terminalCursor: "oklch(0.591646 0.217985 0.584)",
154 terminalSelection: "oklch(0.869588 0.06751 334.899)",
155 terminalScrollbar: "oklch(0.851713 0.055822 336.6)",
156 terminalScrollbarHover: "oklch(0.802407 0.090963 345.892)",
157 },
158 variants: {
159 dark: {
160 canvas: "oklch(0.22813 0.020366 307.469)",
161 chrome: "oklch(0.22813 0.020366 307.469)",
162 toolbar: "oklch(0.22813 0.020366 307.469)",
163 toolbarForeground: "oklch(0.980735 0.004092 301.426)",
164 toolbarBorder: "oklch(0.266943 0.015262 302.425)",
165 toolbarControl: "oklch(0.313674 0.030572 310.061)",
166 toolbarControlForeground: "oklch(0.848252 0.038248 307.961)",
167 toolbarControlHover: "oklch(0.364912 0.050794 308.491)",
168 surface: "oklch(0.267101 0.02016 311.799)",
169 surfaceRaised: "oklch(0.279864 0.021572 309.532)",
170 surfaceOverlay: "oklch(0.154761 0.01316 338.901)",
171 text: "oklch(0.980735 0.004092 301.426)",
172 textMuted: "oklch(0.880303 0.03077 342.696)",
173 border: "oklch(0.266943 0.015262 302.425)",
174 input: "oklch(0.266817 0.02897 344.461)",
175 focus: "oklch(0.591646 0.217985 0.584)",
176 accent: "oklch(0.460685 0.185347 4.099)",
177 accentForeground: "oklch(0.901233 0.057189 343.694)",
178 secondary: "oklch(0.313674 0.030572 310.061)",
179 secondaryForeground: "oklch(0.848252 0.038248 307.961)",
180 muted: "oklch(0.360924 0.021469 316.83)",
181 mutedForeground: "oklch(0.880303 0.03077 342.696)",
182 placeholder: "oklch(0.657087 0.028226 307.985)",
183 secondaryLabel: "oklch(0.880303 0.03077 342.696)",
184 iconMuted: "oklch(0.848252 0.038248 307.961)",
185 error: "oklch(0.458704 0.169677 3.815)",
186 errorForeground: "oklch(0.901233 0.057189 343.694)",
187 errorSurface: "oklch(0.259022 0.04799 340.062)",
188 warning: "oklch(0.76859 0.164659 70.08)",
189 warningForeground: "oklch(0.836861 0.164422 84.429)",
190 warningSurface: "oklch(0.321706 0.036256 60.806)",
191 update: "oklch(0.460685 0.185347 4.099)",
192 updateForeground: "oklch(0.901233 0.057189 343.694)",
193 updateSurface: "oklch(0.256077 0.063004 342.914)",
194 accentSurface: "oklch(0.364912 0.050794 308.491)",
195 accentSurfaceForeground: "oklch(0.964695 0.009139 341.803)",
196 messageSurface: "oklch(0.273791 0.025541 309.079)",
197 messageForeground: "oklch(0.949872 0.021269 306.838)",
198 messageAction: "oklch(0.460685 0.185347 4.099)",
199 messageActionForeground: "oklch(0.901233 0.057189 343.694)",
200 messageActionHover: "oklch(0.458754 0.184639 3.857)",
201 codeBackground: "oklch(0.22813 0.020366 307.469)",
202 codeForeground: "oklch(0.848703 0.064239 306.645)",
203 sidebar: "oklch(0.185778 0.019368 322.159)",
204 sidebarForeground: "oklch(0.967434 0.001326 286.375)",
205 sidebarMutedForeground: "oklch(0.880303 0.03077 342.696)",
206 sidebarControlSurface: "oklch(0.23366 0.026081 338.196)",
207 sidebarRowHover: "oklch(0.23366 0.026081 338.196)",
208 sidebarRowActive: "oklch(0.23366 0.026081 338.196)",
209 sidebarRowSelected: "oklch(0.23366 0.026081 338.196)",
210 sidebarBorder: "oklch(0.269132 0.030766 351.067)",
211 terminalBackground: "oklch(0.22813 0.020366 307.469)",
212 terminalForeground: "oklch(0.980735 0.004092 301.426)",
213 terminalCursor: "oklch(0.591646 0.217985 0.584)",
214 terminalSelection: "oklch(0.313674 0.030572 310.061)",
215 terminalScrollbar: "oklch(0.266817 0.02897 344.461)",
216 terminalScrollbarHover: "oklch(0.360924 0.021469 316.83)",
217 },
218 },
219 sidebarArtwork: true,
220};
221
222export const GROVE_THEME: ThemeDefinition = {
223 id: "grove",
224 label: "Grove",
225 appearance: "light",
226 colors: {
227 canvas: "oklch(0.972369 0.005497 157.15)",
228 chrome: "oklch(0.972369 0.005497 157.15)",
229 toolbar: "oklch(0.972369 0.005497 157.15)",
230 toolbarForeground: "oklch(0.222003 0.03479 328.979)",
231 toolbarBorder: "oklch(0.909438 0.021521 164.612)",
232 toolbarControl: "oklch(0.936464 0.014601 163.554)",
233 toolbarControlForeground: "oklch(0.222003 0.03479 328.979)",
234 toolbarControlHover: "oklch(0.909438 0.021521 164.612)",
235 surface: "oklch(0.972369 0.005497 157.15)",
236 surfaceRaised: "oklch(0.949276 0.004496 159.002)",
237 surfaceOverlay: "oklch(0.932695 0.003778 160.944)",
238 text: "oklch(0.222003 0.03479 328.979)",
239 textMuted: "oklch(0.540472 0.014944 326.176)",
240 border: "oklch(0.864831 0.01312 167.255)",
241 input: "oklch(0.829746 0.016084 168.234)",
242 focus: "oklch(0.523295 0.112292 158.089)",
243 accent: "oklch(0.523295 0.112292 158.089)",
244 accentForeground: "oklch(0.990339 0.008411 325.64)",
245 secondary: "oklch(0.936464 0.014601 163.554)",
246 secondaryForeground: "oklch(0.222003 0.03479 328.979)",
247 muted: "oklch(0.945455 0.012308 162.879)",
248 mutedForeground: "oklch(0.527266 0.012309 320.683)",
249 placeholder: "oklch(0.529681 0.01551 326.299)",
250 secondaryLabel: "oklch(0.540472 0.014944 326.176)",
251 iconMuted: "oklch(0.540472 0.014944 326.176)",
252 error: "oklch(0.637823 0.237287 25.436)",
253 errorForeground: "oklch(0.509494 0.208583 28.513)",
254 errorSurface: "oklch(0.936968 0.014243 26.295)",
255 warning: "oklch(0.772406 0.172798 65.367)",
256 warningForeground: "oklch(0.545036 0.155019 45.359)",
257 warningSurface: "oklch(0.953175 0.02009 93.379)",
258 update: "oklch(0.523295 0.112292 158.089)",
259 updateForeground: "oklch(0.388012 0.080082 158.768)",
260 updateSurface: "oklch(0.900411 0.02384 164.795)",
261 accentSurface: "oklch(0.909438 0.021521 164.612)",
262 accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)",
263 messageSurface: "oklch(0.891377 0.026164 164.929)",
264 messageForeground: "oklch(0.222003 0.03479 328.979)",
265 messageAction: "oklch(0.535028 0.106403 77.549)",
266 messageActionForeground: "oklch(0.990339 0.008411 325.64)",
267 messageActionHover: "oklch(0.488753 0.096536 77.829)",
268 codeBackground: "oklch(0.955888 0.004783 158.391)",
269 codeForeground: "oklch(0.222003 0.03479 328.979)",
270 sidebar: "oklch(0.936464 0.014601 163.554)",
271 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
272 sidebarMutedForeground: "oklch(0.515606 0.011938 318.897)",
273 sidebarControlSurface: "oklch(0.88585 0.011734 166.331)",
274 sidebarRowHover: "oklch(0.886676 0.027374 164.983)",
275 sidebarRowActive: "oklch(0.85335 0.03597 165.158)",
276 sidebarRowSelected: "oklch(0.836654 0.040284 165.149)",
277 sidebarBorder: "oklch(0.860274 0.010287 168.339)",
278 terminalBackground: "oklch(0.972369 0.005497 157.15)",
279 terminalForeground: "oklch(0.222003 0.03479 328.979)",
280 terminalCursor: "oklch(0.523295 0.112292 158.089)",
281 terminalSelection: "oklch(0.891377 0.026164 164.929)",
282 terminalScrollbar: "oklch(0.824752 0.001392 294.641)",
283 terminalScrollbarHover: "oklch(0.755495 0.004415 318.776)",
284 },
285 variants: {
286 dark: {
287 canvas: "oklch(0.260865 0.02152 162.75)",
288 chrome: "oklch(0.260865 0.02152 162.75)",
289 toolbar: "oklch(0.260865 0.02152 162.75)",
290 toolbarForeground: "oklch(0.990339 0.008411 325.64)",
291 toolbarBorder: "oklch(0.464636 0.066083 158.72)",
292 toolbarControl: "oklch(0.380487 0.048313 159.608)",
293 toolbarControlForeground: "oklch(0.990339 0.008411 325.64)",
294 toolbarControlHover: "oklch(0.437021 0.060312 158.962)",
295 surface: "oklch(0.260865 0.02152 162.75)",
296 surfaceRaised: "oklch(0.363192 0.016572 165.32)",
297 surfaceOverlay: "oklch(0.411828 0.014378 166.627)",
298 text: "oklch(0.990339 0.008411 325.64)",
299 textMuted: "oklch(0.666747 0.004239 187.292)",
300 border: "oklch(0.457475 0.044046 160.971)",
301 input: "oklch(0.519849 0.049896 160.863)",
302 focus: "oklch(0.796228 0.133058 157.319)",
303 accent: "oklch(0.796228 0.133058 157.319)",
304 accentForeground: "oklch(0.222003 0.03479 328.979)",
305 secondary: "oklch(0.380487 0.048313 159.608)",
306 secondaryForeground: "oklch(0.990339 0.008411 325.64)",
307 muted: "oklch(0.339728 0.039456 160.274)",
308 mutedForeground: "oklch(0.715427 0.010896 171.428)",
309 placeholder: "oklch(0.739243 0.002222 223.225)",
310 secondaryLabel: "oklch(0.666747 0.004239 187.292)",
311 iconMuted: "oklch(0.666747 0.004239 187.292)",
312 error: "oklch(0.655108 0.221148 23.473)",
313 errorForeground: "oklch(0.704237 0.187511 22.228)",
314 errorSurface: "oklch(0.312773 0.02923 32.121)",
315 warning: "oklch(0.772406 0.172798 65.367)",
316 warningForeground: "oklch(0.829017 0.171221 81.038)",
317 warningSurface: "oklch(0.345524 0.046882 99.736)",
318 update: "oklch(0.796228 0.133058 157.319)",
319 updateForeground: "oklch(0.86276 0.089288 159.704)",
320 updateSurface: "oklch(0.448116 0.062637 158.86)",
321 accentSurface: "oklch(0.437021 0.060312 158.962)",
322 accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)",
323 messageSurface: "oklch(0.470111 0.067221 158.676)",
324 messageForeground: "oklch(0.990339 0.008411 325.64)",
325 messageAction: "oklch(0.791603 0.129713 83.299)",
326 messageActionForeground: "oklch(0.222003 0.03479 328.979)",
327 messageActionHover: "oklch(0.815227 0.117902 84.21)",
328 codeBackground: "oklch(0.312979 0.018942 164.082)",
329 codeForeground: "oklch(0.990339 0.008411 325.64)",
330 sidebar: "oklch(0.309925 0.032827 160.944)",
331 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
332 sidebarMutedForeground: "oklch(0.711387 0.007643 175.89)",
333 sidebarControlSurface: "oklch(0.432727 0.024549 163.654)",
334 sidebarRowHover: "oklch(0.374959 0.047124 159.686)",
335 sidebarRowActive: "oklch(0.41688 0.056069 159.165)",
336 sidebarRowSelected: "oklch(0.437466 0.060406 158.958)",
337 sidebarBorder: "oklch(0.569253 0.015933 167.062)",
338 terminalBackground: "oklch(0.260865 0.02152 162.75)",
339 terminalForeground: "oklch(0.990339 0.008411 325.64)",
340 terminalCursor: "oklch(0.796228 0.133058 157.319)",
341 terminalSelection: "oklch(0.464636 0.066083 158.72)",
342 terminalScrollbar: "oklch(0.594692 0.006862 176.022)",
343 terminalScrollbarHover: "oklch(0.687968 0.00354 193.55)",
344 },
345 },
346 sidebarArtwork: true,
347};
348
349export const OCEAN_THEME: ThemeDefinition = {
350 id: "ocean",
351 label: "Ocean",
352 appearance: "light",
353 colors: {
354 canvas: "oklch(0.974199 0.002856 241.597)",
355 chrome: "oklch(0.974199 0.002856 241.597)",
356 toolbar: "oklch(0.974199 0.002856 241.597)",
357 toolbarForeground: "oklch(0.222003 0.03479 328.979)",
358 toolbarBorder: "oklch(0.91295 0.018827 241.836)",
359 toolbarControl: "oklch(0.939254 0.01193 241.729)",
360 toolbarControlForeground: "oklch(0.222003 0.03479 328.979)",
361 toolbarControlHover: "oklch(0.91295 0.018827 241.836)",
362 surface: "oklch(0.974199 0.002856 241.597)",
363 surfaceRaised: "oklch(0.951058 0.002962 258.339)",
364 surfaceOverlay: "oklch(0.934442 0.003181 269.1)",
365 text: "oklch(0.222003 0.03479 328.979)",
366 textMuted: "oklch(0.541555 0.017468 323.531)",
367 border: "oklch(0.867646 0.013482 252.362)",
368 input: "oklch(0.832939 0.017389 252.598)",
369 focus: "oklch(0.536684 0.120219 247.01)",
370 accent: "oklch(0.536684 0.120219 247.01)",
371 accentForeground: "oklch(0.990339 0.008411 325.64)",
372 secondary: "oklch(0.939254 0.01193 241.729)",
373 secondaryForeground: "oklch(0.222003 0.03479 328.979)",
374 muted: "oklch(0.948004 0.009649 241.695)",
375 mutedForeground: "oklch(0.528741 0.01828 313.823)",
376 placeholder: "oklch(0.530733 0.01795 323.79)",
377 secondaryLabel: "oklch(0.541555 0.017468 323.531)",
378 iconMuted: "oklch(0.541555 0.017468 323.531)",
379 error: "oklch(0.637823 0.237287 25.436)",
380 errorForeground: "oklch(0.509494 0.208583 28.513)",
381 errorSurface: "oklch(0.938747 0.016377 7.186)",
382 warning: "oklch(0.772406 0.172798 65.367)",
383 warningForeground: "oklch(0.546927 0.155556 45.359)",
384 warningSurface: "oklch(0.954846 0.016009 81.731)",
385 update: "oklch(0.536684 0.120219 247.01)",
386 updateForeground: "oklch(0.397497 0.084999 246.523)",
387 updateSurface: "oklch(0.904165 0.021144 241.874)",
388 accentSurface: "oklch(0.91295 0.018827 241.836)",
389 accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)",
390 messageSurface: "oklch(0.895373 0.023469 241.913)",
391 messageForeground: "oklch(0.222003 0.03479 328.979)",
392 messageAction: "oklch(0.493961 0.08175 201.584)",
393 messageActionForeground: "oklch(0.990339 0.008411 325.64)",
394 messageActionHover: "oklch(0.45151 0.074407 201.516)",
395 codeBackground: "oklch(0.957684 0.002906 253.68)",
396 codeForeground: "oklch(0.222003 0.03479 328.979)",
397 sidebar: "oklch(0.939254 0.01193 241.729)",
398 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
399 sidebarMutedForeground: "oklch(0.517366 0.018944 311.433)",
400 sidebarControlSurface: "oklch(0.888479 0.011475 251.638)",
401 sidebarRowHover: "oklch(0.890798 0.024681 241.933)",
402 sidebarRowActive: "oklch(0.858363 0.033325 242.089)",
403 sidebarRowSelected: "oklch(0.842113 0.037689 242.174)",
404 sidebarBorder: "oklch(0.862823 0.011384 256.926)",
405 terminalBackground: "oklch(0.974199 0.002856 241.597)",
406 terminalForeground: "oklch(0.222003 0.03479 328.979)",
407 terminalCursor: "oklch(0.536684 0.120219 247.01)",
408 terminalSelection: "oklch(0.895373 0.023469 241.913)",
409 terminalScrollbar: "oklch(0.826271 0.006191 305.456)",
410 terminalScrollbarHover: "oklch(0.756866 0.008685 313.721)",
411 },
412 variants: {
413 dark: {
414 canvas: "oklch(0.242641 0.024125 250.573)",
415 chrome: "oklch(0.242641 0.024125 250.573)",
416 toolbar: "oklch(0.242641 0.024125 250.573)",
417 toolbarForeground: "oklch(0.990339 0.008411 325.64)",
418 toolbarBorder: "oklch(0.439946 0.0561 243.479)",
419 toolbarControl: "oklch(0.358725 0.043145 244.911)",
420 toolbarControlForeground: "oklch(0.990339 0.008411 325.64)",
421 toolbarControlHover: "oklch(0.413315 0.051874 243.855)",
422 surface: "oklch(0.242641 0.024125 250.573)",
423 surfaceRaised: "oklch(0.348439 0.019942 253.696)",
424 surfaceOverlay: "oklch(0.398517 0.018232 255.72)",
425 text: "oklch(0.990339 0.008411 325.64)",
426 textMuted: "oklch(0.652227 0.01149 273.31)",
427 border: "oklch(0.438653 0.039496 245.44)",
428 input: "oklch(0.500905 0.043574 244.781)",
429 focus: "oklch(0.758933 0.105833 241.548)",
430 accent: "oklch(0.758933 0.105833 241.548)",
431 accentForeground: "oklch(0.222003 0.03479 328.979)",
432 secondary: "oklch(0.358725 0.043145 244.911)",
433 secondaryForeground: "oklch(0.990339 0.008411 325.64)",
434 muted: "oklch(0.319287 0.036766 246.065)",
435 mutedForeground: "oklch(0.691936 0.016294 261.588)",
436 placeholder: "oklch(0.721641 0.010192 281.271)",
437 secondaryLabel: "oklch(0.652227 0.01149 273.31)",
438 iconMuted: "oklch(0.652227 0.01149 273.31)",
439 error: "oklch(0.655108 0.221148 23.473)",
440 errorForeground: "oklch(0.702184 0.189226 22.228)",
441 errorSurface: "oklch(0.298933 0.036443 350.094)",
442 warning: "oklch(0.772406 0.172798 65.367)",
443 warningForeground: "oklch(0.829017 0.171221 81.038)",
444 warningSurface: "oklch(0.329449 0.028712 84.495)",
445 update: "oklch(0.758933 0.105833 241.548)",
446 updateForeground: "oklch(0.840844 0.069217 240.151)",
447 updateSurface: "oklch(0.424017 0.053575 243.695)",
448 accentSurface: "oklch(0.413315 0.051874 243.855)",
449 accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)",
450 messageSurface: "oklch(0.445224 0.056936 243.413)",
451 messageForeground: "oklch(0.990339 0.008411 325.64)",
452 messageAction: "oklch(0.793363 0.105022 199.893)",
453 messageActionForeground: "oklch(0.222003 0.03479 328.979)",
454 messageActionHover: "oklch(0.815308 0.096174 199.862)",
455 codeBackground: "oklch(0.29661 0.021883 251.968)",
456 codeForeground: "oklch(0.990339 0.008411 325.64)",
457 sidebar: "oklch(0.290387 0.032043 247.274)",
458 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
459 sidebarMutedForeground: "oklch(0.69099 0.01395 266.424)",
460 sidebarControlSurface: "oklch(0.417822 0.02535 250.162)",
461 sidebarRowHover: "oklch(0.353381 0.042285 245.043)",
462 sidebarRowActive: "oklch(0.393878 0.048778 244.179)",
463 sidebarRowSelected: "oklch(0.413744 0.051943 243.848)",
464 sidebarBorder: "oklch(0.55859 0.019001 256.223)",
465 terminalBackground: "oklch(0.242641 0.024125 250.573)",
466 terminalForeground: "oklch(0.990339 0.008411 325.64)",
467 terminalCursor: "oklch(0.758933 0.105833 241.548)",
468 terminalSelection: "oklch(0.439946 0.0561 243.479)",
469 terminalScrollbar: "oklch(0.58613 0.012959 267.22)",
470 terminalScrollbarHover: "oklch(0.681569 0.010909 276.465)",
471 },
472 },
473 sidebarArtwork: true,
474};
475
476export const EMBER_THEME: ThemeDefinition = {
477 id: "ember",
478 label: "Ember",
479 appearance: "light",
480 colors: {
481 canvas: "oklch(0.976527 0.002685 60.725)",
482 chrome: "oklch(0.976527 0.002685 60.725)",
483 toolbar: "oklch(0.976527 0.002685 60.725)",
484 toolbarForeground: "oklch(0.222003 0.03479 328.979)",
485 toolbarBorder: "oklch(0.916502 0.01832 49.597)",
486 toolbarControl: "oklch(0.942267 0.01151 50.785)",
487 toolbarControlForeground: "oklch(0.222003 0.03479 328.979)",
488 toolbarControlHover: "oklch(0.916502 0.01832 49.597)",
489 surface: "oklch(0.976527 0.002685 60.725)",
490 surfaceRaised: "oklch(0.953321 0.002701 42.266)",
491 surfaceOverlay: "oklch(0.936659 0.002879 29.96)",
492 text: "oklch(0.222003 0.03479 328.979)",
493 textMuted: "oklch(0.543023 0.017316 331.964)",
494 border: "oklch(0.870631 0.013204 39.431)",
495 input: "oklch(0.836213 0.017153 38.661)",
496 focus: "oklch(0.552831 0.129438 44.656)",
497 accent: "oklch(0.552831 0.129438 44.656)",
498 accentForeground: "oklch(0.990339 0.008411 325.64)",
499 secondary: "oklch(0.942267 0.01151 50.785)",
500 secondaryForeground: "oklch(0.222003 0.03479 328.979)",
501 muted: "oklch(0.950842 0.009273 51.528)",
502 mutedForeground: "oklch(0.530413 0.018453 341.181)",
503 placeholder: "oklch(0.532339 0.017796 331.748)",
504 secondaryLabel: "oklch(0.543023 0.017316 331.964)",
505 iconMuted: "oklch(0.543023 0.017316 331.964)",
506 error: "oklch(0.637823 0.237287 25.436)",
507 errorForeground: "oklch(0.509494 0.208583 28.513)",
508 errorSurface: "oklch(0.941094 0.019938 19.375)",
509 warning: "oklch(0.772406 0.172798 65.367)",
510 warningForeground: "oklch(0.549154 0.156188 45.359)",
511 warningSurface: "oklch(0.957148 0.020843 76.702)",
512 update: "oklch(0.552831 0.129438 44.656)",
513 updateForeground: "oklch(0.408647 0.091207 45.037)",
514 updateSurface: "oklch(0.907902 0.020621 49.36)",
515 accentSurface: "oklch(0.916502 0.01832 49.597)",
516 accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)",
517 messageSurface: "oklch(0.899296 0.022939 49.163)",
518 messageForeground: "oklch(0.222003 0.03479 328.979)",
519 messageAction: "oklch(0.516323 0.161628 24.82)",
520 messageActionForeground: "oklch(0.990339 0.008411 325.64)",
521 messageActionHover: "oklch(0.471223 0.145843 24.688)",
522 codeBackground: "oklch(0.959965 0.002668 47.512)",
523 codeForeground: "oklch(0.222003 0.03479 328.979)",
524 sidebar: "oklch(0.942267 0.01151 50.785)",
525 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
526 sidebarMutedForeground: "oklch(0.519146 0.019214 343.427)",
527 sidebarControlSurface: "oklch(0.891332 0.011179 40.596)",
528 sidebarRowHover: "oklch(0.894819 0.024151 49.073)",
529 sidebarRowActive: "oklch(0.863104 0.032855 48.586)",
530 sidebarRowSelected: "oklch(0.84723 0.037292 48.403)",
531 sidebarBorder: "oklch(0.865593 0.011154 35.246)",
532 terminalBackground: "oklch(0.976527 0.002685 60.725)",
533 terminalForeground: "oklch(0.222003 0.03479 328.979)",
534 terminalCursor: "oklch(0.552831 0.129438 44.656)",
535 terminalSelection: "oklch(0.899296 0.022939 49.163)",
536 terminalScrollbar: "oklch(0.828185 0.005884 349.533)",
537 terminalScrollbarHover: "oklch(0.758584 0.008423 341.16)",
538 },
539 variants: {
540 dark: {
541 canvas: "oklch(0.245899 0.019144 42.044)",
542 chrome: "oklch(0.245899 0.019144 42.044)",
543 toolbar: "oklch(0.245899 0.019144 42.044)",
544 toolbarForeground: "oklch(0.990339 0.008411 325.64)",
545 toolbarBorder: "oklch(0.442681 0.0608 50.795)",
546 toolbarControl: "oklch(0.361499 0.044052 49.515)",
547 toolbarControlForeground: "oklch(0.990339 0.008411 325.64)",
548 toolbarControlHover: "oklch(0.416048 0.055354 50.484)",
549 surface: "oklch(0.245899 0.019144 42.044)",
550 surfaceRaised: "oklch(0.351262 0.01565 37.592)",
551 surfaceOverlay: "oklch(0.401111 0.014308 34.896)",
552 text: "oklch(0.990339 0.008411 325.64)",
553 textMuted: "oklch(0.654017 0.009505 13.287)",
554 border: "oklch(0.44099 0.040202 48.807)",
555 input: "oklch(0.503003 0.045721 49.44)",
556 focus: "oklch(0.762174 0.124117 52.082)",
557 accent: "oklch(0.762174 0.124117 52.082)",
558 accentForeground: "oklch(0.222003 0.03479 328.979)",
559 secondary: "oklch(0.361499 0.044052 49.515)",
560 secondaryForeground: "oklch(0.990339 0.008411 325.64)",
561 muted: "oklch(0.322144 0.03574 48.309)",
562 mutedForeground: "oklch(0.692479 0.015227 30.963)",
563 placeholder: "oklch(0.723533 0.008741 4.515)",
564 secondaryLabel: "oklch(0.654017 0.009505 13.287)",
565 iconMuted: "oklch(0.654017 0.009505 13.287)",
566 error: "oklch(0.655108 0.221148 23.473)",
567 errorForeground: "oklch(0.702184 0.189226 22.228)",
568 errorSurface: "oklch(0.310955 0.059624 24.334)",
569 warning: "oklch(0.772406 0.172798 65.367)",
570 warningForeground: "oklch(0.829017 0.171221 81.038)",
571 warningSurface: "oklch(0.339137 0.055638 66.911)",
572 update: "oklch(0.762174 0.124117 52.082)",
573 updateForeground: "oklch(0.841456 0.079585 53.521)",
574 updateSurface: "oklch(0.426749 0.057547 50.618)",
575 accentSurface: "oklch(0.416048 0.055354 50.484)",
576 accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)",
577 messageSurface: "oklch(0.447961 0.061874 50.849)",
578 messageForeground: "oklch(0.990339 0.008411 325.64)",
579 messageAction: "oklch(0.747955 0.135578 29.432)",
580 messageActionForeground: "oklch(0.222003 0.03479 328.979)",
581 messageActionHover: "oklch(0.775116 0.117953 29.014)",
582 codeBackground: "oklch(0.299662 0.017229 39.973)",
583 codeForeground: "oklch(0.990339 0.008411 325.64)",
584 sidebar: "oklch(0.293349 0.029554 46.882)",
585 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
586 sidebarMutedForeground: "oklch(0.691874 0.012538 24.638)",
587 sidebarControlSurface: "oklch(0.420227 0.022893 43.226)",
588 sidebarRowHover: "oklch(0.356163 0.042933 49.385)",
589 sidebarRowActive: "oklch(0.396617 0.051353 50.201)",
590 sidebarRowSelected: "oklch(0.416477 0.055442 50.489)",
591 sidebarBorder: "oklch(0.560372 0.016998 36.179)",
592 terminalBackground: "oklch(0.245899 0.019144 42.044)",
593 terminalForeground: "oklch(0.990339 0.008411 325.64)",
594 terminalCursor: "oklch(0.762174 0.124117 52.082)",
595 terminalSelection: "oklch(0.442681 0.0608 50.795)",
596 terminalScrollbar: "oklch(0.587861 0.010463 20.444)",
597 terminalScrollbarHover: "oklch(0.682876 0.009156 9.796)",
598 },
599 },
600 sidebarArtwork: true,
601};
602
603export const IRIS_THEME: ThemeDefinition = {
604 id: "iris",
605 label: "Iris",
606 appearance: "light",
607 colors: {
608 canvas: "oklch(0.976531 0.003855 303.226)",
609 chrome: "oklch(0.976531 0.003855 303.226)",
610 toolbar: "oklch(0.976531 0.003855 303.226)",
611 toolbarForeground: "oklch(0.222003 0.03479 328.979)",
612 toolbarBorder: "oklch(0.914882 0.022965 299.986)",
613 toolbarControl: "oklch(0.941387 0.014687 300.474)",
614 toolbarControlForeground: "oklch(0.222003 0.03479 328.979)",
615 toolbarControlHover: "oklch(0.914882 0.022965 299.986)",
616 surface: "oklch(0.976531 0.003855 303.226)",
617 surfaceRaised: "oklch(0.953326 0.004536 307.676)",
618 surfaceOverlay: "oklch(0.936665 0.005041 310.132)",
619 text: "oklch(0.222003 0.03479 328.979)",
620 textMuted: "oklch(0.543042 0.018894 325.652)",
621 border: "oklch(0.869608 0.018226 303.859)",
622 input: "oklch(0.834773 0.023405 303.676)",
623 focus: "oklch(0.525348 0.15373 294.176)",
624 accent: "oklch(0.525348 0.15373 294.176)",
625 accentForeground: "oklch(0.990339 0.008411 325.64)",
626 secondary: "oklch(0.941387 0.014687 300.474)",
627 secondaryForeground: "oklch(0.222003 0.03479 328.979)",
628 muted: "oklch(0.950194 0.011956 300.733)",
629 mutedForeground: "oklch(0.529955 0.022319 321.556)",
630 placeholder: "oklch(0.532177 0.019333 325.784)",
631 secondaryLabel: "oklch(0.543042 0.018894 325.652)",
632 iconMuted: "oklch(0.543042 0.018894 325.652)",
633 error: "oklch(0.637823 0.237287 25.436)",
634 errorForeground: "oklch(0.509494 0.208583 28.513)",
635 errorSurface: "oklch(0.941043 0.019582 4.235)",
636 warning: "oklch(0.772406 0.172798 65.367)",
637 warningForeground: "oklch(0.549154 0.156188 45.359)",
638 warningSurface: "oklch(0.957054 0.016197 69.932)",
639 update: "oklch(0.525348 0.15373 294.176)",
640 updateForeground: "oklch(0.389926 0.10825 294.547)",
641 updateSurface: "oklch(0.90602 0.025754 299.867)",
642 accentSurface: "oklch(0.914882 0.022965 299.986)",
643 accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)",
644 messageSurface: "oklch(0.897143 0.028558 299.758)",
645 messageForeground: "oklch(0.222003 0.03479 328.979)",
646 messageAction: "oklch(0.516084 0.185229 340.776)",
647 messageActionForeground: "oklch(0.990339 0.008411 325.64)",
648 messageActionHover: "oklch(0.471003 0.16748 340.687)",
649 codeBackground: "oklch(0.95997 0.004338 306.542)",
650 codeForeground: "oklch(0.222003 0.03479 328.979)",
651 sidebar: "oklch(0.941387 0.014687 300.474)",
652 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
653 sidebarMutedForeground: "oklch(0.518417 0.023683 320.681)",
654 sidebarControlSurface: "oklch(0.890512 0.0155 303.803)",
655 sidebarRowHover: "oklch(0.892522 0.030022 299.704)",
656 sidebarRowActive: "oklch(0.85971 0.040501 299.36)",
657 sidebarRowSelected: "oklch(0.843236 0.045818 299.198)",
658 sidebarBorder: "oklch(0.864805 0.015938 305.371)",
659 terminalBackground: "oklch(0.976531 0.003855 303.226)",
660 terminalForeground: "oklch(0.222003 0.03479 328.979)",
661 terminalCursor: "oklch(0.525348 0.15373 294.176)",
662 terminalSelection: "oklch(0.897143 0.028558 299.758)",
663 terminalScrollbar: "oklch(0.828195 0.008526 318.858)",
664 terminalScrollbarHover: "oklch(0.758596 0.010892 321.538)",
665 },
666 variants: {
667 dark: {
668 canvas: "oklch(0.225975 0.031062 293.741)",
669 chrome: "oklch(0.225975 0.031062 293.741)",
670 toolbar: "oklch(0.225975 0.031062 293.741)",
671 toolbarForeground: "oklch(0.990339 0.008411 325.64)",
672 toolbarBorder: "oklch(0.395417 0.085554 294.182)",
673 toolbarControl: "oklch(0.325405 0.063614 294.23)",
674 toolbarControlForeground: "oklch(0.990339 0.008411 325.64)",
675 toolbarControlHover: "oklch(0.372436 0.07841 294.204)",
676 surface: "oklch(0.225975 0.031062 293.741)",
677 surfaceRaised: "oklch(0.335291 0.026008 296.394)",
678 surfaceOverlay: "oklch(0.386739 0.024023 297.509)",
679 text: "oklch(0.990339 0.008411 325.64)",
680 textMuted: "oklch(0.640465 0.016197 304.171)",
681 border: "oklch(0.40874 0.058536 295.893)",
682 input: "oklch(0.46756 0.065775 296.265)",
683 focus: "oklch(0.671712 0.169136 293.929)",
684 accent: "oklch(0.671712 0.169136 293.929)",
685 accentForeground: "oklch(0.222003 0.03479 328.979)",
686 secondary: "oklch(0.325405 0.063614 294.23)",
687 secondaryForeground: "oklch(0.990339 0.008411 325.64)",
688 muted: "oklch(0.291515 0.05276 294.209)",
689 mutedForeground: "oklch(0.663321 0.025932 301.862)",
690 placeholder: "oklch(0.706249 0.014508 306.607)",
691 secondaryLabel: "oklch(0.640465 0.016197 304.171)",
692 iconMuted: "oklch(0.640465 0.016197 304.171)",
693 error: "oklch(0.655108 0.221148 23.473)",
694 errorForeground: "oklch(0.702184 0.189226 22.228)",
695 errorSurface: "oklch(0.291658 0.054707 352.238)",
696 warning: "oklch(0.772406 0.172798 65.367)",
697 warningForeground: "oklch(0.829017 0.171221 81.038)",
698 warningSurface: "oklch(0.318952 0.033845 51.646)",
699 update: "oklch(0.671712 0.169136 293.929)",
700 updateForeground: "oklch(0.785032 0.108439 296.344)",
701 updateSurface: "oklch(0.381668 0.081286 294.195)",
702 accentSurface: "oklch(0.372436 0.07841 294.204)",
703 accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)",
704 messageSurface: "oklch(0.399975 0.086965 294.177)",
705 messageForeground: "oklch(0.990339 0.008411 325.64)",
706 messageAction: "oklch(0.789904 0.130063 337.621)",
707 messageActionForeground: "oklch(0.222003 0.03479 328.979)",
708 messageActionHover: "oklch(0.813537 0.114101 337.23)",
709 codeBackground: "oklch(0.281873 0.028308 295.193)",
710 codeForeground: "oklch(0.990339 0.008411 325.64)",
711 sidebar: "oklch(0.266743 0.044689 294.138)",
712 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
713 sidebarMutedForeground: "oklch(0.668773 0.021522 302.949)",
714 sidebarControlSurface: "oklch(0.399977 0.035678 297.031)",
715 sidebarRowHover: "oklch(0.320808 0.062152 294.23)",
716 sidebarRowActive: "oklch(0.355677 0.073167 294.217)",
717 sidebarRowSelected: "oklch(0.372806 0.078525 294.203)",
718 sidebarBorder: "oklch(0.545895 0.027522 299.871)",
719 terminalBackground: "oklch(0.225975 0.031062 293.741)",
720 terminalForeground: "oklch(0.990339 0.008411 325.64)",
721 terminalCursor: "oklch(0.671712 0.169136 293.929)",
722 terminalSelection: "oklch(0.395417 0.085554 294.182)",
723 terminalScrollbar: "oklch(0.578663 0.017888 302.229)",
724 terminalScrollbarHover: "oklch(0.676012 0.015271 305.433)",
725 },
726 },
727 sidebarArtwork: true,
728};
729
730export const BUILT_IN_THEMES: ReadonlyArray<ThemeDefinition> = [
731 T3_CHAT_THEME,
732 GROVE_THEME,
733 OCEAN_THEME,
734 EMBER_THEME,
735 IRIS_THEME,
736];
737
738export function getBuiltInTheme(id: string): ThemeDefinition | null {
739 return BUILT_IN_THEMES.find((theme) => theme.id === id) ?? null;
740}
741
742export function getThemeColorsForAppearance(
743 theme: ThemeDefinition,
744 appearance: ThemeAppearance,
745): ThemeColors | null {
746 if (theme.appearance === appearance) return theme.colors;
747 return theme.variants?.[appearance] ?? null;
748}
packages/shared/src/themePreview.test.ts created+20
...@@ -0,0 +1,20 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 mixThemePreviewBase,
5 STANDARD_THEME_PREVIEW_COLORS,
6 THEME_PREVIEW_RENDER_SPECS,
7} from "./themePreview.js";
8
9describe("theme preview", () => {
10 it("keeps the desktop preview geometry stable across clients", () => {
11 expect(THEME_PREVIEW_RENDER_SPECS.light!.accent.center).toEqual([0.72, 0.22]);
12 expect(THEME_PREVIEW_RENDER_SPECS.dark!.accent.middleOpacity).toBe(0.62);
13 expect(THEME_PREVIEW_RENDER_SPECS.dark!.action.center).toEqual([0.82, 0.18]);
14 });
15
16 it("mixes the standard canvas bases in OKLab", () => {
17 expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.light!, "light")).toBe("#fdfdfd");
18 expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.dark!, "dark")).toBe("#0a0a0a");
19 });
20});
packages/shared/src/themePreview.ts created+145
...@@ -0,0 +1,145 @@
1import type { ThemeAppearance } from "./themePalettes.js";
2
3export type ThemePreviewColors = Readonly<{
4 canvas: string;
5 accent: string;
6 messageAction: string;
7}>;
8
9/** The standard T3 Code artwork is not a built-in theme, so its preview colors live here. */
10export const STANDARD_THEME_PREVIEW_COLORS: Readonly<Record<ThemeAppearance, ThemePreviewColors>> =
11 {
12 light: {
13 canvas: "#fcfcfc",
14 accent: "#f4f4f5",
15 messageAction: "#4f46e5",
16 },
17 dark: {
18 canvas: "#0a0a0a",
19 accent: "#1c1c1f",
20 messageAction: "#8b9cff",
21 },
22 };
23
24export type ThemePreviewRenderSpec = Readonly<{
25 baseTarget: string;
26 baseWeight: number;
27 accent: Readonly<{
28 center: readonly [x: number, y: number];
29 middleOffset: number;
30 middleOpacity: number;
31 endOffset: number;
32 }>;
33 action: Readonly<{
34 center: readonly [x: number, y: number];
35 startOpacity: number;
36 endOffset: number;
37 }>;
38 scale: number;
39 blurAt56Px: number;
40}>;
41
42/** Shared geometry and falloff for the web and native theme preview orbs. */
43export const THEME_PREVIEW_RENDER_SPECS: Readonly<Record<ThemeAppearance, ThemePreviewRenderSpec>> =
44 {
45 light: {
46 baseTarget: "#ffffff",
47 baseWeight: 0.8,
48 accent: {
49 center: [0.72, 0.22],
50 middleOffset: 0.28,
51 middleOpacity: 0.72,
52 endOffset: 0.58,
53 },
54 action: {
55 center: [0.18, 0.82],
56 startOpacity: 0.45,
57 endOffset: 0.55,
58 },
59 scale: 1.1,
60 blurAt56Px: 3,
61 },
62 dark: {
63 baseTarget: "#09090b",
64 baseWeight: 0.8,
65 accent: {
66 center: [0.28, 0.78],
67 middleOffset: 0.28,
68 middleOpacity: 0.62,
69 endOffset: 0.58,
70 },
71 action: {
72 center: [0.82, 0.18],
73 startOpacity: 0.45,
74 endOffset: 0.55,
75 },
76 scale: 1.1,
77 blurAt56Px: 3,
78 },
79 };
80
81type Oklab = Readonly<{ l: number; a: number; b: number }>;
82
83const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)/;
84const HEX_PATTERN = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;
85
86function srgbToLinear(value: number): number {
87 return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
88}
89
90function linearToSrgb(value: number): number {
91 const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
92 return Math.round(Math.min(1, Math.max(0, converted)) * 255);
93}
94
95function parseOklab(value: string): Oklab | null {
96 const oklch = OKLCH_PATTERN.exec(value);
97 if (oklch) {
98 const lightness = Number(oklch[1]);
99 const chroma = Number(oklch[2]);
100 const hue = (Number(oklch[3]) * Math.PI) / 180;
101 return { l: lightness, a: chroma * Math.cos(hue), b: chroma * Math.sin(hue) };
102 }
103
104 const hex = HEX_PATTERN.exec(value);
105 if (!hex) return null;
106 const red = srgbToLinear(Number.parseInt(hex[1]!, 16) / 255);
107 const green = srgbToLinear(Number.parseInt(hex[2]!, 16) / 255);
108 const blue = srgbToLinear(Number.parseInt(hex[3]!, 16) / 255);
109 const lRoot = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue);
110 const mRoot = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue);
111 const sRoot = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue);
112 return {
113 l: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot,
114 a: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot,
115 b: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot,
116 };
117}
118
119function oklabToHex(color: Oklab): string {
120 const lPrime = color.l + 0.3963377774 * color.a + 0.2158037573 * color.b;
121 const mPrime = color.l - 0.1055613458 * color.a - 0.0638541728 * color.b;
122 const sPrime = color.l - 0.0894841775 * color.a - 1.291485548 * color.b;
123 const l = lPrime ** 3;
124 const m = mPrime ** 3;
125 const s = sPrime ** 3;
126 const channels = [
127 linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
128 linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
129 linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
130 ];
131 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
132}
133
134export function mixThemePreviewBase(colors: ThemePreviewColors, mode: ThemeAppearance): string {
135 const spec = THEME_PREVIEW_RENDER_SPECS[mode]!;
136 const canvas = parseOklab(colors.canvas);
137 const target = parseOklab(spec.baseTarget);
138 if (!canvas || !target) return colors.canvas;
139 const targetWeight = 1 - spec.baseWeight;
140 return oklabToHex({
141 l: canvas.l * spec.baseWeight + target.l * targetWeight,
142 a: canvas.a * spec.baseWeight + target.a * targetWeight,
143 b: canvas.b * spec.baseWeight + target.b * targetWeight,
144 });
145}
scripts/mobile-showcase.config.ts+21
...@@ -1,3 +1,9 @@...@@ -1,3 +1,9 @@
1import {
2 MOBILE_DEFAULT_THEME_ID,
3 MOBILE_THEME_IDS,
4 type MobileThemeId,
5} from "@t3tools/shared/themePalettes";
6
1import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts";7import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts";
28
3export { SHOWCASE_SCENES };9export { SHOWCASE_SCENES };
...@@ -5,6 +11,11 @@ export type { ShowcaseScene };...@@ -5,6 +11,11 @@ export type { ShowcaseScene };
511
6export type ShowcaseAppearance = "light" | "dark";12export type ShowcaseAppearance = "light" | "dark";
713
14/** Every palette the mobile appearance settings can select. */
15export const SHOWCASE_THEMES = MOBILE_THEME_IDS;
16export const DEFAULT_SHOWCASE_THEME = MOBILE_DEFAULT_THEME_ID;
17export type ShowcaseTheme = MobileThemeId;
18
8export interface ShowcaseStoreAssetSpec {19export interface ShowcaseStoreAssetSpec {
9 readonly store: "apple" | "google-play";20 readonly store: "apple" | "google-play";
10 /** Device directory relative to ShowcaseConfig.outputDirectory. */21 /** Device directory relative to ShowcaseConfig.outputDirectory. */
...@@ -25,6 +36,8 @@ export interface ShowcaseIosDevice {...@@ -25,6 +36,8 @@ export interface ShowcaseIosDevice {
25 readonly simulatorDeviceType?: string;36 readonly simulatorDeviceType?: string;
26 /** Appearance used when the CLI does not pass --appearance. */37 /** Appearance used when the CLI does not pass --appearance. */
27 readonly appearance: ShowcaseAppearance;38 readonly appearance: ShowcaseAppearance;
39 /** Palette used when the CLI does not pass --theme. */
40 readonly theme: ShowcaseTheme;
28 /** Orientation applied by the capture harness. Defaults to portrait. */41 /** Orientation applied by the capture harness. Defaults to portrait. */
29 readonly orientation?: "portrait" | "landscape";42 readonly orientation?: "portrait" | "landscape";
30 readonly scenes: ReadonlyArray<ShowcaseScene>;43 readonly scenes: ReadonlyArray<ShowcaseScene>;
...@@ -38,6 +51,8 @@ export interface ShowcaseAndroidDevice {...@@ -38,6 +51,8 @@ export interface ShowcaseAndroidDevice {
38 readonly avd: string;51 readonly avd: string;
39 /** Appearance used when the CLI does not pass --appearance. */52 /** Appearance used when the CLI does not pass --appearance. */
40 readonly appearance: ShowcaseAppearance;53 readonly appearance: ShowcaseAppearance;
54 /** Palette used when the CLI does not pass --theme. */
55 readonly theme: ShowcaseTheme;
41 /** Native ABI used by the AVD, from its config.ini `abi.type`. */56 /** Native ABI used by the AVD, from its config.ini `abi.type`. */
42 readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a";57 readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a";
43 readonly scenes: ReadonlyArray<ShowcaseScene>;58 readonly scenes: ReadonlyArray<ShowcaseScene>;
...@@ -92,6 +107,7 @@ const config: ShowcaseConfig = {...@@ -92,6 +107,7 @@ const config: ShowcaseConfig = {
92 simulator: "iPhone 17 Pro Max",107 simulator: "iPhone 17 Pro Max",
93 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max",108 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max",
94 appearance: "dark",109 appearance: "dark",
110 theme: DEFAULT_SHOWCASE_THEME,
95 scenes: ["thread", "terminal", "review", "threads", "environments"],111 scenes: ["thread", "terminal", "review", "threads", "environments"],
96 storeAsset: {112 storeAsset: {
97 store: "apple",113 store: "apple",
...@@ -108,6 +124,7 @@ const config: ShowcaseConfig = {...@@ -108,6 +124,7 @@ const config: ShowcaseConfig = {
108 simulator: "T3 Showcase iPhone 14 Plus",124 simulator: "T3 Showcase iPhone 14 Plus",
109 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus",125 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus",
110 appearance: "dark",126 appearance: "dark",
127 theme: DEFAULT_SHOWCASE_THEME,
111 scenes: ["thread", "terminal", "review", "threads", "environments"],128 scenes: ["thread", "terminal", "review", "threads", "environments"],
112 storeAsset: {129 storeAsset: {
113 store: "apple",130 store: "apple",
...@@ -124,6 +141,7 @@ const config: ShowcaseConfig = {...@@ -124,6 +141,7 @@ const config: ShowcaseConfig = {
124 simulator: "iPad Pro 13-inch (M5)",141 simulator: "iPad Pro 13-inch (M5)",
125 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB",142 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB",
126 appearance: "dark",143 appearance: "dark",
144 theme: DEFAULT_SHOWCASE_THEME,
127 orientation: "landscape",145 orientation: "landscape",
128 scenes: ["thread", "terminal", "review", "threads", "environments"],146 scenes: ["thread", "terminal", "review", "threads", "environments"],
129 storeAsset: {147 storeAsset: {
...@@ -143,6 +161,7 @@ const config: ShowcaseConfig = {...@@ -143,6 +161,7 @@ const config: ShowcaseConfig = {
143 // Blacksmith Linux runner can use KVM acceleration.161 // Blacksmith Linux runner can use KVM acceleration.
144 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),162 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
145 appearance: "dark",163 appearance: "dark",
164 theme: DEFAULT_SHOWCASE_THEME,
146 viewport: {165 viewport: {
147 width: 1080,166 width: 1080,
148 height: 1920,167 height: 1920,
...@@ -165,6 +184,7 @@ const config: ShowcaseConfig = {...@@ -165,6 +184,7 @@ const config: ShowcaseConfig = {
165 avd: "Pixel_10_Pro",184 avd: "Pixel_10_Pro",
166 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),185 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
167 appearance: "dark",186 appearance: "dark",
187 theme: DEFAULT_SHOWCASE_THEME,
168 viewport: {188 viewport: {
169 width: 1080,189 width: 1080,
170 height: 1920,190 height: 1920,
...@@ -187,6 +207,7 @@ const config: ShowcaseConfig = {...@@ -187,6 +207,7 @@ const config: ShowcaseConfig = {
187 avd: "Pixel_10_Pro",207 avd: "Pixel_10_Pro",
188 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),208 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
189 appearance: "dark",209 appearance: "dark",
210 theme: DEFAULT_SHOWCASE_THEME,
190 viewport: {211 viewport: {
191 width: 1440,212 width: 1440,
192 height: 2560,213 height: 2560,
scripts/mobile-showcase.test.ts+58-2
...@@ -2,7 +2,9 @@ import { assert, it } from "@effect/vitest";...@@ -2,7 +2,9 @@ import { assert, it } from "@effect/vitest";
2import { PNG } from "pngjs";2import { PNG } from "pngjs";
33
4import showcaseConfig, {4import showcaseConfig, {
5 DEFAULT_SHOWCASE_THEME,
5 resolveShowcaseAndroidAbi,6 resolveShowcaseAndroidAbi,
7 SHOWCASE_THEMES,
6 type ShowcaseConfig,8 type ShowcaseConfig,
7 type ShowcaseStoreAssetSpec,9 type ShowcaseStoreAssetSpec,
8} from "./mobile-showcase.config.ts";10} from "./mobile-showcase.config.ts";
...@@ -55,6 +57,7 @@ const config: ShowcaseConfig = {...@@ -55,6 +57,7 @@ const config: ShowcaseConfig = {
55 platform: "ios",57 platform: "ios",
56 simulator: "iPhone Test",58 simulator: "iPhone Test",
57 appearance: "dark",59 appearance: "dark",
60 theme: "t3-code",
58 scenes: ["thread", "review"],61 scenes: ["thread", "review"],
59 storeAsset: appleSpec,62 storeAsset: appleSpec,
60 },63 },
...@@ -63,6 +66,7 @@ const config: ShowcaseConfig = {...@@ -63,6 +66,7 @@ const config: ShowcaseConfig = {
63 platform: "android",66 platform: "android",
64 avd: "Pixel_Test",67 avd: "Pixel_Test",
65 appearance: "light",68 appearance: "light",
69 theme: "t3-code",
66 scenes: ["thread", "terminal"],70 scenes: ["thread", "terminal"],
67 storeAsset: googleSpec,71 storeAsset: googleSpec,
68 },72 },
...@@ -95,6 +99,23 @@ it("rejects unsupported system appearances", () => {...@@ -95,6 +99,23 @@ it("rejects unsupported system appearances", () => {
95 );99 );
96});100});
97101
102it("parses repeatable and expanded theme filters", () => {
103 assert.deepStrictEqual(
104 [...parseShowcaseCliArgs(["--theme", "ocean", "--theme", "ember"]).themes],
105 ["ocean", "ember"],
106 );
107 assert.deepStrictEqual(
108 [...parseShowcaseCliArgs(["--theme", "all"]).themes],
109 [...SHOWCASE_THEMES],
110 );
111});
112
113// The app normalizes an unknown id back to its default palette, so a typo here
114// would otherwise produce screenshots labeled with a theme they do not show.
115it("rejects unsupported themes instead of capturing the default palette", () => {
116 assert.throws(() => parseShowcaseCliArgs(["--theme", "sunset"]), /Unsupported theme 'sunset'/u);
117});
118
98it("parses validation-only mode", () => {119it("parses validation-only mode", () => {
99 assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true);120 assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true);
100});121});
...@@ -146,8 +167,35 @@ it("expands both appearances into independent upload-ready directories", () => {...@@ -146,8 +167,35 @@ it("expands both appearances into independent upload-ready directories", () => {
146 directory: showcaseCaptureDirectory("/captures", capture),167 directory: showcaseCaptureDirectory("/captures", capture),
147 })),168 })),
148 [169 [
149 { appearance: "light", directory: "/captures/apple/iphone-test/light" },170 { appearance: "light", directory: "/captures/apple/iphone-test/light/t3-code" },
150 { appearance: "dark", directory: "/captures/apple/iphone-test/dark" },171 { appearance: "dark", directory: "/captures/apple/iphone-test/dark/t3-code" },
172 ],
173 );
174});
175
176// Every palette needs its own leaf folder: one directory holding several themes
177// would mix upload slots and break the per-store screenshot count limits.
178it("expands themes into independent upload-ready directories per appearance", () => {
179 const options = parseShowcaseCliArgs([
180 "--device",
181 "phone",
182 "--appearance",
183 "both",
184 "--theme",
185 "ocean",
186 "--theme",
187 "ember",
188 ]);
189
190 assert.deepStrictEqual(
191 planShowcaseCaptures(config, options).map((capture) =>
192 showcaseCaptureDirectory("/captures", capture),
193 ),
194 [
195 "/captures/apple/iphone-test/light/ocean",
196 "/captures/apple/iphone-test/light/ember",
197 "/captures/apple/iphone-test/dark/ocean",
198 "/captures/apple/iphone-test/dark/ember",
151 ],199 ],
152 );200 );
153});201});
...@@ -211,6 +259,14 @@ it("enforces store screenshot count limits", () => {...@@ -211,6 +259,14 @@ it("enforces store screenshot count limits", () => {
211 assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u);259 assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u);
212});260});
213261
262it("defaults every device to the app's own palette", () => {
263 assert.equal(DEFAULT_SHOWCASE_THEME, "t3-code");
264 assert.equal(
265 showcaseConfig.devices.every((device) => device.theme === DEFAULT_SHOWCASE_THEME),
266 true,
267 );
268});
269
214it("configures every default device with an exact upload-ready store target", () => {270it("configures every default device with an exact upload-ready store target", () => {
215 assert.deepStrictEqual(271 assert.deepStrictEqual(
216 showcaseConfig.devices.map((device) => [272 showcaseConfig.devices.map((device) => [
scripts/mobile-showcase.ts+48-12
...@@ -20,6 +20,8 @@ import showcaseConfig, {...@@ -20,6 +20,8 @@ import showcaseConfig, {
20 type ShowcaseStoreAssetSpec,20 type ShowcaseStoreAssetSpec,
21 SHOWCASE_SCENES,21 SHOWCASE_SCENES,
22 type ShowcaseScene,22 type ShowcaseScene,
23 SHOWCASE_THEMES,
24 type ShowcaseTheme,
23} from "./mobile-showcase.config.ts";25} from "./mobile-showcase.config.ts";
24import {26import {
25 SHOWCASE_ENVIRONMENTS,27 SHOWCASE_ENVIRONMENTS,
...@@ -76,6 +78,7 @@ interface CliOptions {...@@ -76,6 +78,7 @@ interface CliOptions {
76 readonly deviceIds: ReadonlySet<string>;78 readonly deviceIds: ReadonlySet<string>;
77 readonly scenes: ReadonlySet<ShowcaseScene>;79 readonly scenes: ReadonlySet<ShowcaseScene>;
78 readonly appearances: ReadonlySet<ShowcaseAppearance>;80 readonly appearances: ReadonlySet<ShowcaseAppearance>;
81 readonly themes: ReadonlySet<ShowcaseTheme>;
79 readonly skipBuild: boolean;82 readonly skipBuild: boolean;
80 readonly skipMetro: boolean;83 readonly skipMetro: boolean;
81 readonly keepRunning: boolean;84 readonly keepRunning: boolean;
...@@ -87,6 +90,7 @@ export interface ShowcaseCapture {...@@ -87,6 +90,7 @@ export interface ShowcaseCapture {
87 readonly device: ShowcaseDevice;90 readonly device: ShowcaseDevice;
88 readonly scenes: ReadonlyArray<ShowcaseScene>;91 readonly scenes: ReadonlyArray<ShowcaseScene>;
89 readonly appearance: ShowcaseAppearance;92 readonly appearance: ShowcaseAppearance;
93 readonly theme: ShowcaseTheme;
90}94}
9195
92interface IosCaptureCleanup {96interface IosCaptureCleanup {
...@@ -223,9 +227,16 @@ export function validateStoreAssetCount(...@@ -223,9 +227,16 @@ export function validateStoreAssetCount(
223227
224export function showcaseCaptureDirectory(228export function showcaseCaptureDirectory(
225 outputDirectory: string,229 outputDirectory: string,
226 capture: Pick<ShowcaseCapture, "device" | "appearance">,230 capture: Pick<ShowcaseCapture, "device" | "appearance" | "theme">,
227): string {231): string {
228 return NodePath.join(outputDirectory, capture.device.storeAsset.directory, capture.appearance);232 // Each palette owns a leaf folder so one upload slot never mixes themes and
233 // every folder keeps a store-legal screenshot count of its own.
234 return NodePath.join(
235 outputDirectory,
236 capture.device.storeAsset.directory,
237 capture.appearance,
238 capture.theme,
239 );
229}240}
230241
231async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise<void> {242async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise<void> {
...@@ -276,6 +287,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {...@@ -276,6 +287,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
276 const deviceIds = new Set<string>();287 const deviceIds = new Set<string>();
277 const scenes = new Set<ShowcaseScene>();288 const scenes = new Set<ShowcaseScene>();
278 const appearances = new Set<ShowcaseAppearance>();289 const appearances = new Set<ShowcaseAppearance>();
290 const themes = new Set<ShowcaseTheme>();
279 let skipBuild = false;291 let skipBuild = false;
280 let skipMetro = false;292 let skipMetro = false;
281 let keepRunning = false;293 let keepRunning = false;
...@@ -318,6 +330,18 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {...@@ -318,6 +330,18 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
318 appearances.add(value);330 appearances.add(value);
319 }331 }
320 index += 1;332 index += 1;
333 } else if (argument === "--theme") {
334 const value = argumentValue(args, index, argument);
335 if (value === "all") {
336 for (const theme of SHOWCASE_THEMES) themes.add(theme);
337 } else if (SHOWCASE_THEMES.some((theme) => theme === value)) {
338 themes.add(value as ShowcaseTheme);
339 } else {
340 // The app silently falls back to its default palette for an unknown id,
341 // so reject it here rather than shipping a mislabeled screenshot.
342 throw new Error(`Unsupported theme '${value}'. Use ${SHOWCASE_THEMES.join(", ")}, or all.`);
343 }
344 index += 1;
321 } else if (argument === "--skip-build") {345 } else if (argument === "--skip-build") {
322 skipBuild = true;346 skipBuild = true;
323 } else if (argument === "--skip-metro") {347 } else if (argument === "--skip-metro") {
...@@ -340,6 +364,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {...@@ -340,6 +364,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
340 deviceIds,364 deviceIds,
341 scenes,365 scenes,
342 appearances,366 appearances,
367 themes,
343 skipBuild,368 skipBuild,
344 skipMetro,369 skipMetro,
345 keepRunning,370 keepRunning,
...@@ -350,7 +375,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {...@@ -350,7 +375,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
350375
351export function planShowcaseCaptures(376export function planShowcaseCaptures(
352 config: ShowcaseConfig,377 config: ShowcaseConfig,
353 options: Pick<CliOptions, "platforms" | "deviceIds" | "scenes" | "appearances">,378 options: Pick<CliOptions, "platforms" | "deviceIds" | "scenes" | "appearances" | "themes">,
354): ReadonlyArray<ShowcaseCapture> {379): ReadonlyArray<ShowcaseCapture> {
355 const captures = config.devices380 const captures = config.devices
356 .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform))381 .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform))
...@@ -358,14 +383,18 @@ export function planShowcaseCaptures(...@@ -358,14 +383,18 @@ export function planShowcaseCaptures(
358 .flatMap((device) => {383 .flatMap((device) => {
359 const appearances =384 const appearances =
360 options.appearances.size === 0 ? [device.appearance] : options.appearances;385 options.appearances.size === 0 ? [device.appearance] : options.appearances;
361 return [...appearances].map((appearance) => ({386 const themes = options.themes.size === 0 ? [device.theme] : options.themes;
362 device,387 return [...appearances].flatMap((appearance) =>
363 appearance,388 [...themes].map((theme) => ({
364 scenes:389 device,
365 options.scenes.size === 0390 appearance,
366 ? device.scenes391 theme,
367 : device.scenes.filter((scene) => options.scenes.has(scene)),392 scenes:
368 }));393 options.scenes.size === 0
394 ? device.scenes
395 : device.scenes.filter((scene) => options.scenes.has(scene)),
396 })),
397 );
369 })398 })
370 .filter((capture) => capture.scenes.length > 0);399 .filter((capture) => capture.scenes.length > 0);
371400
...@@ -393,6 +422,7 @@ Options:...@@ -393,6 +422,7 @@ Options:
393 --scene <name> Capture one scene (repeatable)422 --scene <name> Capture one scene (repeatable)
394 --appearance light|dark|both423 --appearance light|dark|both
395 Override the configured appearance424 Override the configured appearance
425 --theme <id>|all Override the configured palette (repeatable)
396 --skip-build Reuse the existing simulator app / debug APK426 --skip-build Reuse the existing simulator app / debug APK
397 --skip-metro Reuse an already running showcase Metro server427 --skip-metro Reuse an already running showcase Metro server
398 --keep-running Leave devices and Metro running after capture428 --keep-running Leave devices and Metro running after capture
...@@ -400,12 +430,13 @@ Options:...@@ -400,12 +430,13 @@ Options:
400 --list Print this help and the configured matrix430 --list Print this help and the configured matrix
401431
402Scenes: ${SHOWCASE_SCENES.join(", ")}432Scenes: ${SHOWCASE_SCENES.join(", ")}
433Themes: ${SHOWCASE_THEMES.join(", ")}
403434
404Configured devices:435Configured devices:
405${config.devices436${config.devices
406 .map((device) => {437 .map((device) => {
407 const target = device.platform === "ios" ? device.simulator : device.avd;438 const target = device.platform === "ios" ? device.simulator : device.avd;
408 return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark} (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance}) [${device.scenes.join(", ")}]`;439 return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark}/<theme> (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance} ${device.theme}) [${device.scenes.join(", ")}]`;
409 })440 })
410 .join("\n")}441 .join("\n")}
411`);442`);
...@@ -946,6 +977,8 @@ async function captureIos(...@@ -946,6 +977,8 @@ async function captureIos(
946 JSON.stringify(pairingUrls),977 JSON.stringify(pairingUrls),
947 "--showcaseScene",978 "--showcaseScene",
948 firstScene,979 firstScene,
980 "--showcaseTheme",
981 capture.theme,
949 // The app rotates itself; Simulator menu UI scripting needs macOS982 // The app rotates itself; Simulator menu UI scripting needs macOS
950 // Accessibility permission that CI runners do not grant to osascript.983 // Accessibility permission that CI runners do not grant to osascript.
951 "--showcaseOrientation",984 "--showcaseOrientation",
...@@ -1204,6 +1237,9 @@ async function captureAndroid(...@@ -1204,6 +1237,9 @@ async function captureAndroid(
1204 "--es",1237 "--es",
1205 "showcaseScene",1238 "showcaseScene",
1206 firstScene,1239 firstScene,
1240 "--es",
1241 "showcaseTheme",
1242 capture.theme,
1207 ANDROID_PACKAGE,1243 ANDROID_PACKAGE,
1208 ]);1244 ]);
1209 for (const [sceneIndex, scene] of capture.scenes.entries()) {1245 for (const [sceneIndex, scene] of capture.scenes.entries()) {