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:
2121 - both
2222 - dark
2323 - 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
2538permissions:
2639 contents: read
......@@ -33,7 +46,9 @@ jobs:
3346 name: iPhone 6.9, iPhone 6.5, and iPad 13
3447 if: inputs.platform == 'all' || inputs.platform == 'ios'
3548 runs-on: blacksmith-12vcpu-macos-26
36 timeout-minutes: 60
49 # 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 }}
3752 steps:
3853 - name: Checkout
3954 uses: actions/checkout@v6
......@@ -62,10 +77,10 @@ jobs:
6277 "$vp_pnpm_bin/pnpm" --version
6378
6479 - 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
6782 - name: Validate App Store Connect assets
68 run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only
83 run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only
6984
7085 - name: Upload iOS screenshots
7186 if: always()
......@@ -80,7 +95,9 @@ jobs:
8095 name: Android phone, 7-inch tablet, and 10-inch tablet
8196 if: inputs.platform == 'all' || inputs.platform == 'android'
8297 runs-on: blacksmith-16vcpu-ubuntu-2404
83 timeout-minutes: 60
98 # 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 }}
84101 env:
85102 T3_SHOWCASE_ANDROID_ABI: x86_64
86103 steps:
......@@ -137,10 +154,10 @@ jobs:
137154 cores: 8
138155 ram-size: 4096M
139156 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
142159 - name: Validate Google Play assets
143 run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only
160 run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only
144161
145162 - name: Upload Android screenshots
146163 if: always()
apps/mobile/global.css+15-5
......@@ -12,6 +12,7 @@
1212 /* Page backgrounds */
1313 --color-screen: #f2f2f7;
1414 --color-sheet: rgba(242, 242, 247, 0.98);
15 --color-sheet-solid: #f2f2f7;
1516
1617 /* Card / surface */
1718 --color-card: #ffffff;
......@@ -39,13 +40,16 @@
3940 /* Primary action */
4041 --color-primary: #262626;
4142 --color-primary-foreground: #ffffff;
42 --color-primary-shadow: rgba(0, 0, 0, 0.18);
43 --color-primary-shadow: #000000;
4344
4445 /* Secondary action */
4546 --color-secondary: #ffffff;
4647 --color-secondary-foreground: #262626;
4748 --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
5054 /* Danger */
5155 --color-danger: #fef2f2;
......@@ -56,7 +60,7 @@
5660 --color-input: #ffffff;
5761 --color-input-border: rgba(0, 0, 0, 0.1);
5862 --color-sidebar-search: rgba(118, 118, 128, 0.12);
59 --color-placeholder: #a3a3a3;
63 --color-placeholder: #737373;
6064
6165 /* Icons */
6266 --color-icon: #262626;
......@@ -90,6 +94,7 @@
9094 --color-user-bubble: #007aff;
9195 --color-user-bubble-foreground: #ffffff;
9296 --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
97 --color-user-bubble-skill-foreground: #f0abfc;
9398
9499 /* Drawer / modal backdrop */
95100 --color-backdrop: rgba(0, 0, 0, 0.22);
......@@ -106,6 +111,7 @@
106111 /* Page backgrounds */
107112 --color-screen: #0a0a0a;
108113 --color-sheet: rgba(14, 14, 14, 0.98);
114 --color-sheet-solid: #0e0e0e;
109115
110116 /* Card / surface */
111117 --color-card: #171717;
......@@ -133,13 +139,16 @@
133139 /* Primary action */
134140 --color-primary: #f5f5f5;
135141 --color-primary-foreground: #0a0a0a;
136 --color-primary-shadow: rgba(0, 0, 0, 0.22);
142 --color-primary-shadow: #000000;
137143
138144 /* Secondary action */
139145 --color-secondary: rgba(255, 255, 255, 0.04);
140146 --color-secondary-foreground: #f5f5f5;
141147 --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
144153 /* Danger */
145154 --color-danger: rgba(239, 68, 68, 0.14);
......@@ -184,6 +193,7 @@
184193 --color-user-bubble: #0a84ff;
185194 --color-user-bubble-foreground: #ffffff;
186195 --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
196 --color-user-bubble-skill-foreground: #f0abfc;
187197
188198 /* Drawer / modal backdrop */
189199 --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() {
2222 storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene")
2323 }
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
2531 Function("prepareShowcaseCapture") {
2632 // Android app data is cleared by the host runner before launch.
2733 }
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+13
......@@ -33,6 +33,19 @@ public final class T3NativeControlsModule: Module {
3333 return arguments[flagIndex + 1]
3434 }
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
3649 Function("getShowcaseOrientation") { () -> String? in
3750 let arguments = ProcessInfo.processInfo.arguments
3851 guard
apps/mobile/src/App.tsx+42-35
......@@ -2,11 +2,11 @@ import { BlurTargetView } from "expo-blur";
22import * as Linking from "expo-linking";
33import * as SplashScreen from "expo-splash-screen";
44import { useEffect } from "react";
5import { StatusBar, useColorScheme } from "react-native";
5import { StatusBar } from "react-native";
66import { GestureHandlerRootView } from "react-native-gesture-handler";
77import { KeyboardProvider } from "react-native-keyboard-controller";
88import { SafeAreaProvider } from "react-native-safe-area-context";
9import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native";
9import { createStaticNavigation } from "@react-navigation/native";
1010
1111import { RegistryContext } from "@effect/atom-react";
1212import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
......@@ -22,6 +22,7 @@ import { appAtomRegistry } from "./state/atom-registry";
2222import { OverlayPortalHost } from "./components/OverlayPortal";
2323import { appBlurTargetRef } from "./lib/appBlurTarget";
2424import { useThemeColor } from "./lib/useThemeColor";
25import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
2526
2627import "../global.css";
2728
......@@ -58,45 +59,51 @@ function SplashScreenCoordinator() {
5859}
5960
6061export default function App() {
61 const colorScheme = useColorScheme();
62 const statusBarBg = useThemeColor("--color-status-bar");
63
6462 return (
6563 <RegistryContext.Provider value={appAtomRegistry}>
6664 <CloudAuthProvider>
6765 <AppearancePreferencesProvider>
68 <SplashScreenCoordinator />
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>
66 <AppContent />
9867 </AppearancePreferencesProvider>
9968 </CloudAuthProvider>
10069 </RegistryContext.Provider>
10170 );
10271}
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 {
1111 type NativeStackNavigationOptions,
1212} from "@react-navigation/native-stack";
1313import { useEffect, useRef } from "react";
14import { Platform, Pressable, ScrollView, StyleSheet } from "react-native";
14import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
1515import { useResolveClassNames } from "uniwind";
1616
1717import { AppText as Text } from "./components/AppText";
......@@ -71,11 +71,7 @@ import {
7171} from "./features/sharing/incoming-share-presentation";
7272import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
7373import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
74import {
75 FORM_SHEET_PRESENTATION_OPTIONS,
76 NATIVE_SHEET_SURFACE_COLOR,
77 NATIVE_SHEET_SURFACE_CONTENT_STYLE,
78} from "./native/sheet-surface";
74import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
7975import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
8076
8177const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
......@@ -96,11 +92,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
9692 headerLargeTitle: false,
9793 headerShadowVisible: false,
9894 headerShown: true,
99 headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED
100 ? { backgroundColor: "transparent" }
101 : NATIVE_SHEET_SURFACE_COLOR !== undefined
102 ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
103 : undefined,
95 headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined,
10496 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
10597 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
10698 scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined,
......@@ -115,12 +107,6 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = {
115107 headerLargeTitle: false,
116108 headerShadowVisible: false,
117109 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,
124110 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
125111 headerTransparent: false,
126112 unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,
......@@ -507,7 +493,6 @@ export const RootStack = createNativeStackNavigator({
507493 linking: `${THREAD_LINKING_PREFIX}/files`,
508494 options: {
509495 ...GLASS_HEADER_OPTIONS,
510 contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE,
511496 title: "Files",
512497 },
513498 }),
......@@ -636,7 +621,11 @@ export const RootStack = createNativeStackNavigator({
636621 // The whole new-task flow (choose project → draft → add project) shares
637622 // draft state via NewTaskFlowProvider. The expo-router era mounted it in
638623 // 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 ),
640629 options: {
641630 gestureEnabled: true,
642631 headerShown: false,
apps/mobile/src/components/AndroidAnchoredMenu.tsx+4-2
......@@ -3,11 +3,12 @@ import { BlurView } from "expo-blur";
33import type { ReactNode } from "react";
44import { useCallback, useEffect, useRef, useState } from "react";
55import type { StyleProp, ViewStyle } from "react-native";
6import { BackHandler, Pressable, ScrollView, useColorScheme, View } from "react-native";
6import { BackHandler, Pressable, ScrollView, View } from "react-native";
77import { useKeyboardState } from "react-native-keyboard-controller";
88import Animated, { FadeIn } from "react-native-reanimated";
99
1010import { appBlurTargetRef } from "../lib/appBlurTarget";
11import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1112import { useThemeColor } from "../lib/useThemeColor";
1213import { cn } from "../lib/cn";
1314import { type AppSymbolName, SymbolView } from "./AppSymbol";
......@@ -79,7 +80,8 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
7980 const anchorRef = useRef<View>(null);
8081 const overlayRef = useRef<View>(null);
8182
82 const isDarkMode = useColorScheme() === "dark";
83 const { themeAppearance } = useAppearancePreferences();
84 const isDarkMode = themeAppearance === "dark";
8385 const keyboardVisible = useKeyboardState((state) => state.isVisible);
8486 const keyboardHeight = useKeyboardState((state) => state.height);
8587 const rippleColor = useThemeColor("--color-subtle");
apps/mobile/src/components/AppSymbol.tsx+4
......@@ -49,6 +49,7 @@ import {
4949 IconLink,
5050 IconMessage,
5151 IconMinus,
52 IconMoon,
5253 IconNetwork,
5354 IconPalette,
5455 IconPin,
......@@ -62,6 +63,7 @@ import {
6263 IconServer,
6364 IconSettings,
6465 IconSparkles,
66 IconSun,
6567 IconLayoutSidebarRight,
6668 IconTerminal2,
6769 IconTextDecrease,
......@@ -111,6 +113,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
111113 "doc.on.doc": IconCopy,
112114 "doc.text": IconFileText,
113115 ellipsis: IconDots,
116 moon: IconMoon,
114117 "ellipsis.circle": IconDotsCircleHorizontal,
115118 "exclamationmark.triangle": IconAlertTriangle,
116119 eye: IconEye,
......@@ -139,6 +142,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
139142 "slider.horizontal.3": IconAdjustmentsHorizontal,
140143 "square.and.pencil": IconEdit,
141144 "square.split.2x1": IconLayoutColumns,
145 "sun.max": IconSun,
142146 "stop.fill": IconPlayerStopFilled,
143147 terminal: IconTerminal2,
144148 "text.bubble": IconMessage,
apps/mobile/src/components/ComposerToolbar.tsx+8-6
......@@ -4,7 +4,6 @@ import {
44 Pressable,
55 ScrollView,
66 View,
7 useColorScheme,
87 type LayoutChangeEvent,
98 type NativeScrollEvent,
109 type NativeSyntheticEvent,
......@@ -13,6 +12,8 @@ import {
1312} from "react-native";
1413
1514import { useThemeColor } from "../lib/useThemeColor";
15import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
16import { themeColorWithAlpha } from "../lib/mobileTheme";
1617import { cn } from "../lib/cn";
1718import { AppText as Text } from "./AppText";
1819import { SymbolView } from "./AppSymbol";
......@@ -213,21 +214,22 @@ export function ComposerToolbarButton(props: {
213214 readonly className?: string;
214215 readonly style?: StyleProp<ViewStyle>;
215216}) {
216 const isDarkMode = useColorScheme() === "dark";
217 const { themeAppearance } = useAppearancePreferences();
218 const isDarkMode = themeAppearance === "dark";
217219 const iconColor = useThemeColor("--color-icon");
218220 const iconSubtle = useThemeColor("--color-icon-subtle");
219221 const primaryFg = useThemeColor("--color-primary-foreground");
220222 const dangerFg = useThemeColor("--color-danger-foreground");
221223 const variant = props.variant ?? "default";
222224 const isCircle = !props.label && props.showChevron === false;
223 const defaultBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
224 const activeBorderColor = isDarkMode ? "rgba(255,255,255,0.13)" : "rgba(0,0,0,0.1)";
225 const defaultBorderColor = useThemeColor("--color-border-subtle");
226 const activeBorderColor = useThemeColor("--color-border");
225227 const filledBorderColor =
226228 variant === "danger"
227 ? "rgba(255,255,255,0.14)"
229 ? themeColorWithAlpha(String(dangerFg), 0.14)
228230 : props.disabled
229231 ? defaultBorderColor
230 : "rgba(255,255,255,0.18)";
232 : themeColorWithAlpha(String(primaryFg), 0.18);
231233 const iconTintColor =
232234 variant === "primary"
233235 ? props.disabled
apps/mobile/src/components/ControlPill.tsx+4-2
......@@ -8,8 +8,9 @@ import {
88 type ReactNode,
99 useRef,
1010} from "react";
11import { Platform, Pressable, useColorScheme, View } from "react-native";
11import { Platform, Pressable, View } from "react-native";
1212import { useThemeColor } from "../lib/useThemeColor";
13import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1314
1415import { cn } from "../lib/cn";
1516import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
......@@ -117,7 +118,8 @@ export function ControlPillMenu(
117118 readonly className?: string;
118119 },
119120) {
120 const isDarkMode = useColorScheme() === "dark";
121 const { themeAppearance } = useAppearancePreferences();
122 const isDarkMode = themeAppearance === "dark";
121123
122124 if (Platform.OS === "android") {
123125 // 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";
22import type { ReactNode } from "react";
33import {
44 Platform,
5 useColorScheme,
65 View,
76 type ColorValue,
87 type StyleProp,
......@@ -10,6 +9,7 @@ import {
109 type ViewStyle,
1110} from "react-native";
1211import { useThemeColor } from "../lib/useThemeColor";
12import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1313
1414interface GlassSurfaceProps extends Omit<ViewProps, "className"> {
1515 readonly children: ReactNode;
......@@ -29,7 +29,8 @@ export function GlassSurface({
2929 style,
3030 ...props
3131}: GlassSurfaceProps) {
32 const isDarkMode = useColorScheme() === "dark";
32 const { themeAppearance } = useAppearancePreferences();
33 const isDarkMode = themeAppearance === "dark";
3334 const borderColor = useThemeColor("--color-border");
3435 const glassSurface = useThemeColor("--color-glass-surface");
3536 const glassTint = useThemeColor("--color-glass-tint");
apps/mobile/src/components/LoadingScreen.tsx+3-2
......@@ -1,6 +1,7 @@
1import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native";
1import { ActivityIndicator, StatusBar, View } from "react-native";
22import { useSafeAreaInsets } from "react-native-safe-area-context";
33import { useThemeColor } from "../lib/useThemeColor";
4import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
45
56import { AppText as Text } from "./AppText";
67import { BrandMark } from "./BrandMark";
......@@ -9,7 +10,7 @@ export function LoadingScreen(props: {
910 readonly message: string;
1011 readonly messagePlacement?: "above-spinner" | "below-spinner";
1112}) {
12 const colorScheme = useColorScheme();
13 const { themeAppearance: colorScheme } = useAppearancePreferences();
1314 const screenBg = useThemeColor("--color-screen");
1415 const insets = useSafeAreaInsets();
1516 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";
33
44import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
55import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
6import { useThemeColor } from "../lib/useThemeColor";
67
78export function PierreEntryIcon(props: {
89 readonly path: string;
......@@ -11,8 +12,9 @@ export function PierreEntryIcon(props: {
1112 readonly style?: StyleProp<ImageStyle>;
1213}) {
1314 const size = props.size ?? 16;
15 const folderColor = useThemeColor("--color-icon-subtle");
1416 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" />;
1618 }
1719
1820 return (
apps/mobile/src/components/ProviderIcon.tsx+3-2
......@@ -1,5 +1,5 @@
1import { useColorScheme } from "react-native";
21import { Path, Svg } from "react-native-svg";
2import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
33
44type ProviderIconProps = {
55 readonly provider: string | null | undefined;
......@@ -7,7 +7,8 @@ type ProviderIconProps = {
77};
88
99export function ProviderIcon(props: ProviderIconProps) {
10 const isDarkMode = useColorScheme() === "dark";
10 const { themeAppearance } = useAppearancePreferences();
11 const isDarkMode = themeAppearance === "dark";
1112 const size = props.size ?? 16;
1213 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";
99import {
1010 ActivityIndicator,
1111 Pressable,
12 Switch,
1312 type NativeSyntheticEvent,
1413 type TextLayoutEventData,
1514 View,
1615} from "react-native";
1716
1817import { AppText as Text } from "../../components/AppText";
18import { ThemedSwitch } from "../../components/ThemedSwitch";
1919import { cn } from "../../lib/cn";
2020import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
2121import { useThemeColor } from "../../lib/useThemeColor";
......@@ -275,8 +275,6 @@ function CloudEnvironmentRowShell(props: {
275275 readonly statusText?: string;
276276 readonly value: boolean;
277277}) {
278 const activeTrack = String(useThemeColor("--color-switch-active"));
279 const track = String(useThemeColor("--color-secondary-border"));
280278 const chevron = useThemeColor("--color-chevron");
281279 const isRetrying =
282280 props.connectionState === "connecting" || props.connectionState === "reconnecting";
......@@ -389,11 +387,9 @@ function CloudEnvironmentRowShell(props: {
389387 ) : null}
390388 </StatusContainer>
391389 </View>
392 <Switch
390 <ThemedSwitch
393391 disabled={props.disabled}
394 ios_backgroundColor={track}
395392 onValueChange={props.onValueChange}
396 trackColor={{ false: track, true: activeTrack }}
397393 value={props.value}
398394 />
399395 </View>
apps/mobile/src/features/files/SourceFileSurface.tsx+10-13
......@@ -2,14 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import { AsyncResult } from "effect/unstable/reactivity";
33import type { ComponentType } from "react";
44import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
5import {
6 FlatList,
7 ScrollView,
8 Text as NativeText,
9 useColorScheme,
10 useWindowDimensions,
11 View,
12} from "react-native";
5import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native";
136
147import { AppText as Text } from "../../components/AppText";
158import { LoadingStrip } from "../../components/LoadingStrip";
......@@ -23,6 +16,7 @@ import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
2316import { cn } from "../../lib/cn";
2417import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
2518import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
19import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2620import {
2721 buildNativeSourceTokens,
2822 NATIVE_SOURCE_CONTENT_WIDTH,
......@@ -115,8 +109,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
115109});
116110
117111function useSourceFileModel(props: SourceFileSurfaceProps) {
118 const colorScheme = useColorScheme();
119 const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";
112 const { themeAppearance: theme } = useAppearancePreferences();
120113 const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]);
121114 const { contents: normalizedContents, lines, rowsJson } = document;
122115 const targetIndex =
......@@ -159,8 +152,9 @@ function NativeSourceFileSurface(
159152) {
160153 const { NativeView, onRefresh } = props;
161154 const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
155 const { themeAppearance, themeId } = useAppearancePreferences();
162156 const { width: viewportWidth } = useWindowDimensions();
163 const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props);
157 const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props);
164158 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
165159 const handlePullToRefresh = useCallback(async () => {
166160 if (!onRefresh) {
......@@ -178,7 +172,10 @@ function NativeSourceFileSurface(
178172 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
179173 [targetIndex],
180174 );
181 const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]);
175 const themeJson = useMemo(
176 () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId)),
177 [themeAppearance, themeId],
178 );
182179 const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]);
183180 const contentWidth = codeWordBreak
184181 ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24)
......@@ -191,7 +188,7 @@ function NativeSourceFileSurface(
191188 collapsable={false}
192189 testID="source-native-code-view"
193190 style={{ flex: 1 }}
194 appearanceScheme={theme}
191 appearanceScheme={themeAppearance}
195192 contentResetKey={props.path}
196193 contentWidth={contentWidth}
197194 initialRowIndex={targetIndex ?? -1}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+8-5
......@@ -1,7 +1,7 @@
11import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
22import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
33import { useCallback, useEffect, useRef, useState } from "react";
4import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";
4import { ActivityIndicator, Platform, View } from "react-native";
55import { useSafeAreaInsets } from "react-native-safe-area-context";
66import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
77import {
......@@ -35,6 +35,7 @@ import {
3535} from "../layout/native-mail-search-toolbar";
3636import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
3737import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
38import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
3839import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
3940import { FileMarkdownPreview } from "./FileMarkdownPreview";
4041import { FileTreeBrowser } from "./FileTreeBrowser";
......@@ -242,10 +243,10 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
242243 const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =
243244 useAdaptiveWorkspaceLayout();
244245 const [searchQuery, setSearchQuery] = useState("");
245 const colorScheme = useColorScheme();
246246 const isAndroid = Platform.OS === "android";
247 const highlightTheme = colorScheme === "dark" ? "dark" : "light";
247 const { themeAppearance: highlightTheme } = useAppearancePreferences();
248248 const iconColor = String(useThemeColor("--color-icon-muted"));
249 const sheetSurfaceColor = String(useThemeColor("--color-sheet-solid"));
249250 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
250251 props.route.params,
251252 );
......@@ -362,10 +363,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
362363
363364 return (
364365 <>
365 {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx.
366 Only genuinely dynamic options are set here. */}
366 {/* Static header config (glass preset and title) lives in Stack.tsx. The
367 live sheet color stays dynamic here so the FlatList can remain the
368 direct scene child for native scroll-edge sampling. */}
367369 <NativeStackScreenOptions
368370 options={{
371 contentStyle: { backgroundColor: sheetSurfaceColor },
369372 headerShown: !isAndroid,
370373 unstable_headerSubtitle:
371374 Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+3-3
......@@ -1,7 +1,7 @@
11import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";
22import { SymbolView } from "../../components/AppSymbol";
33import { 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";
55import {
66 Screen,
77 ScreenStack,
......@@ -15,6 +15,7 @@ import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
1515import { useThemeColor } from "../../lib/useThemeColor";
1616import { projectEnvironment } from "../../state/projects";
1717import { useEnvironmentQuery } from "../../state/query";
18import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1819import { FileTreeBrowser } from "./FileTreeBrowser";
1920import { preloadWorkspaceFileContents } from "./preload-workspace-file";
2021
......@@ -27,8 +28,7 @@ export function ThreadFileNavigatorPane(props: {
2728 readonly onSelectFile: (path: string) => void;
2829}) {
2930 const [searchQuery, setSearchQuery] = useState("");
30 const colorScheme = useColorScheme();
31 const highlightTheme = colorScheme === "dark" ? "dark" : "light";
31 const { themeAppearance: highlightTheme } = useAppearancePreferences();
3232 const iconColor = String(useThemeColor("--color-icon-muted"));
3333 const foregroundColor = String(useThemeColor("--color-foreground"));
3434 const sheetColor = String(useThemeColor("--color-sheet"));
apps/mobile/src/features/layout/workspace-pane-divider.tsx+11-12
......@@ -1,14 +1,8 @@
11import { useCallback, useMemo, useRef, useState } from "react";
2import {
3 Platform,
4 PlatformColor,
5 Pressable,
6 StyleSheet,
7 View,
8 type AccessibilityActionEvent,
9} from "react-native";
2import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native";
103import { Gesture, GestureDetector } from "react-native-gesture-handler";
114import { runOnJS } from "react-native-reanimated";
5import { useThemeColor } from "../../lib/useThemeColor";
126
137const ACCESSIBILITY_RESIZE_STEP = 24;
148
......@@ -28,6 +22,8 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
2822 latestProps.current = props;
2923 const [hovered, setHovered] = useState(false);
3024 const [dragging, setDragging] = useState(false);
25 const dividerColor = useThemeColor("--color-border");
26 const activeDividerColor = useThemeColor("--color-primary");
3127 const handleResizeStart = useCallback(() => {
3228 setDragging(true);
3329 latestProps.current.onResizeStart?.();
......@@ -84,7 +80,13 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
8480 onHoverIn={() => setHovered(true)}
8581 onHoverOut={() => setHovered(false)}
8682 >
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 />
8890 </Pressable>
8991 </GestureDetector>
9092 );
......@@ -93,14 +95,11 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
9395const styles = StyleSheet.create({
9496 line: {
9597 alignSelf: "center",
96 backgroundColor:
97 Platform.OS === "ios" ? PlatformColor("separator") : "rgba(120, 120, 128, 0.28)",
9898 height: "100%",
9999 opacity: 0.7,
100100 width: StyleSheet.hairlineWidth,
101101 },
102102 activeLine: {
103 backgroundColor: Platform.OS === "ios" ? PlatformColor("systemBlueColor") : "#0a84ff",
104103 opacity: 1,
105104 width: 2,
106105 },
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+4-12
......@@ -2,14 +2,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"
22import { TextInputWrapper } from "expo-paste-input";
33import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
44import { useCallback, useEffect, useMemo, useState } from "react";
5import {
6 Platform,
7 Pressable,
8 ScrollView,
9 View,
10 useColorScheme,
11 useWindowDimensions,
12} from "react-native";
5import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
136import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
147import { useSafeAreaInsets } from "react-native-safe-area-context";
158import ImageViewing from "react-native-image-viewing";
......@@ -33,10 +26,10 @@ import {
3326 useReviewCommentTarget,
3427} from "./reviewCommentSelection";
3528import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
29import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
3630import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRendering";
3731import {
3832 highlightReviewSelectedLines,
39 type ReviewDiffTheme,
4033 type ReviewHighlightedToken,
4134} from "./shikiReviewHighlighter";
4235
......@@ -52,7 +45,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
5245 const navigation = useNavigation();
5346 const insets = useSafeAreaInsets();
5447 const { width } = useWindowDimensions();
55 const colorScheme = useColorScheme();
48 const { themeAppearance: selectedTheme } = useAppearancePreferences();
5649 const iconTint = String(useThemeColor("--color-icon"));
5750 const target = useReviewCommentTarget();
5851 const { codeSurface } = useAppearanceCodeSurface();
......@@ -72,7 +65,6 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
7265 const lastLine = selectedLines[selectedLines.length - 1] ?? null;
7366 const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;
7467 const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;
75 const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
7668 const canSubmit =
7769 commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId;
7870 const selectionLabel =
......@@ -162,7 +154,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
162154 }, [attachments, commentText, dismissComposer, environmentId, target, threadId]);
163155
164156 return (
165 <View className="flex-1">
157 <View className="flex-1 bg-sheet">
166158 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">
167159 <View
168160 className="flex-1 px-5"
apps/mobile/src/features/review/ReviewSheet.tsx+13-14
......@@ -26,7 +26,6 @@ import {
2626 ScrollView,
2727 type NativeSyntheticEvent,
2828 StyleSheet,
29 useColorScheme,
3029 View,
3130} from "react-native";
3231import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -42,6 +41,7 @@ import { useThemeColor } from "../../lib/useThemeColor";
4241import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
4342import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
4443import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
44import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
4545import {
4646 useAdaptiveWorkspaceLayout,
4747 useAdaptiveWorkspacePaneRole,
......@@ -73,7 +73,7 @@ import { resolveReviewAvailability } from "./reviewAvailability";
7373import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
7474import { buildReviewSectionMenu } from "./review-section-menu";
7575import type { ReviewSectionItem } from "./reviewModel";
76import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene";
76import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal";
7777
7878const REVIEW_HEADER_SPACING = 0;
7979const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
......@@ -97,6 +97,7 @@ function ReviewSelectionActionBar(props: {
9797 readonly onOpenComment: (() => void) | null;
9898 readonly onClear: () => void;
9999}) {
100 const foreground = useThemeColor("--color-primary-foreground");
100101 if (!props.title) {
101102 return null;
102103 }
......@@ -106,10 +107,10 @@ function ReviewSelectionActionBar(props: {
106107 <SymbolView
107108 name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"}
108109 size={16}
109 tintColor="#ffffff"
110 tintColor={foreground}
110111 type="monochrome"
111112 />
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>
113114 </>
114115 );
115116
......@@ -128,22 +129,22 @@ function ReviewSelectionActionBar(props: {
128129 >
129130 {props.onOpenComment ? (
130131 <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"
132133 onPress={props.onOpenComment}
133134 >
134135 {content}
135136 </Pressable>
136137 ) : (
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">
138139 {content}
139140 </View>
140141 )}
141142
142143 <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"
144145 onPress={props.onClear}
145146 >
146 <SymbolView name="xmark" size={16} tintColor="#ffffff" type="monochrome" />
147 <SymbolView name="xmark" size={16} tintColor={foreground} type="monochrome" />
147148 </Pressable>
148149 </View>
149150 );
......@@ -346,7 +347,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
346347 const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
347348 const navigation = useNavigation();
348349 const insets = useSafeAreaInsets();
349 const colorScheme = useColorScheme();
350 const { themeAppearance: selectedTheme } = useAppearancePreferences();
350351 const headerIcon = String(useThemeColor("--color-icon"));
351352 const { environmentId, threadId } = props.route.params;
352353 const environment = useEnvironmentPresentation(environmentId);
......@@ -371,7 +372,6 @@ export function ReviewSheet(props: ReviewSheetProps) {
371372 // selected thread (it always does when reached from the thread's toolbar).
372373 const gitMenuAvailable =
373374 selectedThread !== null && String(selectedThread.id) === String(threadId);
374 const selectedTheme = colorScheme === "dark" ? "dark" : "light";
375375 // With a solid (non-overlay) header the content lays out below the header
376376 // natively, so no manual top inset is needed. (Android renders its own
377377 // in-flow AndroidScreenHeader, so it needs no inset either.)
......@@ -436,7 +436,6 @@ export function ReviewSheet(props: ReviewSheetProps) {
436436 sectionId: selectedSection?.id ?? null,
437437 diff: selectedSection?.diff,
438438 data: nativeReviewDiffData,
439 scheme: selectedTheme,
440439 collapsedFileIds,
441440 viewedFileIds,
442441 selectedRowIds: commentSelection.selectedRowIds,
......@@ -444,7 +443,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
444443 });
445444 const showcaseReviewKey =
446445 SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection
447 ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}`
446 ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}:${nativeBridge.themeId}`
448447 : null;
449448 const handleNativeDebug = useCallback(
450449 (event: NativeSyntheticEvent<Record<string, unknown>>) => {
......@@ -457,9 +456,9 @@ export function ReviewSheet(props: ReviewSheetProps) {
457456 return;
458457 }
459458 showcasedReviewDrawRef.current = showcaseReviewKey;
460 markNativeShowcaseReady("review");
459 reportShowcaseSceneRendered({ scene: "review", themeId: nativeBridge.themeId });
461460 },
462 [nativeBridge.onDebug, showcaseReviewKey],
461 [nativeBridge.onDebug, nativeBridge.themeId, showcaseReviewKey],
463462 );
464463
465464 const handleSelectFile = useCallback(
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+25
......@@ -1,6 +1,8 @@
11import { describe, expect, it } from "vite-plus/test";
2import { MOBILE_THEME_IDS } from "../../lib/mobileTheme";
23
34import {
5 createNativeReviewDiffTheme,
46 getCachedNativeReviewDiffData,
57 type BuildNativeReviewDiffDataInput,
68} from "./nativeReviewDiffAdapter";
......@@ -54,3 +56,26 @@ describe("getCachedNativeReviewDiffData", () => {
5456 expect(changed).not.toBe(first);
5557 });
5658});
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";
88import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
99import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
1010import { 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";
1217import { computeWordAltDiffRanges } from "./reviewWordDiffs";
1318import {
1419 getReviewFilePreviewState,
......@@ -20,6 +25,9 @@ import type { ReviewInlineComment } from "./reviewCommentSelection";
2025
2126const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;
2227const 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
2432export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
2533export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
......@@ -28,6 +36,23 @@ export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle(
2836 resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
2937);
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
3156export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) {
3257 return {
3358 rowHeight: codeSurface.rowHeight,
......@@ -112,21 +137,28 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment
112137
113138export function createNativeReviewDiffTheme(
114139 scheme: TerminalAppearanceScheme,
140 themeId: MobileThemeId = DEFAULT_MOBILE_THEME_ID,
115141): NativeReviewDiffTheme {
116 const terminalTheme = getPierreTerminalTheme(scheme);
117 const [, terminalRed, , , terminalBlue] = terminalTheme.palette;
142 const terminalTheme = getMobileTerminalTheme(themeId, scheme);
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
119151 if (scheme === "dark") {
120152 return {
121153 // Match the app surface (--color-sheet) so code views blend with the rest of
122154 // the app instead of using a distinct code-editor background.
123 background: "#0e0e0e",
124 text: terminalTheme.foreground,
125 mutedText: terminalTheme.mutedForeground,
126 headerBackground: "#0e0e0e",
127 border: terminalTheme.border,
128 hunkBackground: "#071f28",
129 hunkText: terminalBlue ?? "#009fff",
155 background,
156 text: nativeColor(appTheme["--color-md-code-text"]),
157 mutedText: nativeColor(appTheme["--color-foreground-muted"]),
158 headerBackground: background,
159 border: nativeColor(appTheme["--color-border"]),
160 hunkBackground: nativeColor(appTheme["--color-subtle-strong"]),
161 hunkText: nativeColor(appTheme["--color-primary"]),
130162 addBackground: "#0d2f28",
131163 deleteBackground: "#391415",
132164 addBar: "#00cab1",
......@@ -139,13 +171,13 @@ export function createNativeReviewDiffTheme(
139171 return {
140172 // Match the app surface (--color-sheet) so code views blend with the rest of the
141173 // app instead of using a distinct code-editor background.
142 background: "#f2f2f7",
143 text: "#070707",
144 mutedText: terminalTheme.mutedForeground,
145 headerBackground: "#f2f2f7",
146 border: terminalTheme.border,
147 hunkBackground: "#e0f2ff",
148 hunkText: terminalBlue ?? "#009fff",
174 background,
175 text: nativeColor(appTheme["--color-md-code-text"]),
176 mutedText: nativeColor(appTheme["--color-foreground-muted"]),
177 headerBackground: background,
178 border: nativeColor(appTheme["--color-border"]),
179 hunkBackground: nativeColor(appTheme["--color-subtle-strong"]),
180 hunkText: nativeColor(appTheme["--color-primary"]),
149181 addBackground: "#e5f8f5",
150182 deleteBackground: "#ffe6e7",
151183 addBar: "#00cab1",
apps/mobile/src/features/review/useNativeReviewDiffBridge.ts+4-4
......@@ -1,9 +1,9 @@
11import { useCallback, useMemo, useState } from "react";
22import type { NativeSyntheticEvent } from "react-native";
33
4import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
54import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter";
65import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
6import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
77import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";
88import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys";
99
......@@ -14,7 +14,6 @@ export function useNativeReviewDiffBridge(input: {
1414 readonly sectionId: string | null;
1515 readonly diff: string | null | undefined;
1616 readonly data: NativeReviewDiffData;
17 readonly scheme: NativeReviewDiffHighlightScheme;
1817 readonly collapsedFileIds: ReadonlyArray<string>;
1918 readonly viewedFileIds: ReadonlyArray<string>;
2019 readonly selectedRowIds: ReadonlyArray<string>;
......@@ -25,18 +24,18 @@ export function useNativeReviewDiffBridge(input: {
2524 collapsedFileIds,
2625 data,
2726 diff,
28 scheme,
2927 sectionId,
3028 selectedRowIds,
3129 threadKey,
3230 viewedFileIds,
3331 } = input;
3432 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
33 const { themeAppearance: scheme, themeId } = useAppearancePreferences();
3534 const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>(
3635 () => new Set(),
3736 );
3837
39 const theme = useMemo(() => createNativeReviewDiffTheme(scheme), [scheme]);
38 const theme = useMemo(() => createNativeReviewDiffTheme(scheme, themeId), [scheme, themeId]);
4039 const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]);
4140 const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]);
4241 const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]);
......@@ -106,6 +105,7 @@ export function useNativeReviewDiffBridge(input: {
106105 );
107106
108107 return {
108 themeId,
109109 theme,
110110 rowsJson,
111111 collapsedFileIdsJson,
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+2
......@@ -7,6 +7,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
77import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
88import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
99import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";
10import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection";
1011
1112export function SettingsAppearanceRouteScreen() {
1213 const navigation = useNavigation();
......@@ -29,6 +30,7 @@ export function SettingsAppearanceRouteScreen() {
2930 paddingBottom: Math.max(insets.bottom, 18) + 18,
3031 }}
3132 >
33 <ThemeAppearanceSection />
3234 <TextAppearanceSection />
3335 <TerminalAppearanceSection />
3436 <CodeAppearanceSection />
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+1-8
......@@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
22import { useNavigation } from "@react-navigation/native";
33import { SymbolView } from "../../components/AppSymbol";
44import type { EnvironmentId } from "@t3tools/contracts";
5import { useCallback, useEffect, useState } from "react";
5import { useCallback, useState } from "react";
66import { Platform, ScrollView, View } from "react-native";
77import { useSafeAreaInsets } from "react-native-safe-area-context";
88
......@@ -20,7 +20,6 @@ import {
2020 SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
2121 SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS,
2222} from "../showcase/showcaseEnvironmentRows";
23import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene";
2423
2524const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
2625
......@@ -48,12 +47,6 @@ export function SettingsEnvironmentsRouteScreen() {
4847 const accentColor = useThemeColor("--color-icon-muted");
4948 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
5750 const handleToggle = useCallback((environmentId: EnvironmentId) => {
5851 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
5952 }, []);
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+101-19
......@@ -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
34import { useAtomSet, useAtomValue } from "@effect/atom-react";
45import { AsyncResult } from "effect/unstable/reactivity";
......@@ -9,16 +10,37 @@ import {
910 resolveAppearance,
1011 resolveAppearancePreferences,
1112 resolveTextScaleVariables,
12 type AppearancePreferences,
1313 type ResolvedAppearance,
1414} from "../../../lib/appearancePreferences";
1515import { 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";
1628import { cacheTerminalFontSize } from "../../terminal/terminalUiState";
1729
1830interface AppearancePreferencesContextValue {
1931 /** Effective values with base-size derivation applied. Use this for rendering. */
2032 readonly appearance: ResolvedAppearance;
33 readonly themeId: MobileThemeId;
34 readonly themeIds: MobileThemeIds;
35 readonly themeMode: MobileThemeMode;
36 readonly themeAppearance: MobileThemeAppearance;
2137 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;
2244 readonly setBaseFontSize: (value: number) => void;
2345 /** Pass null to clear the override and follow the base font size. */
2446 readonly setTerminalFontSize: (value: number | null) => void;
......@@ -30,46 +52,85 @@ interface AppearancePreferencesContextValue {
3052const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null);
3153
3254/**
33 * Injects the scaled `--text-*` variables into Uniwind so every
34 * className-based text size (`text-sm`, `text-base`, ...) re-resolves live.
35 * Updates the current theme last so the active stylesheet settles correctly.
55 * Injects palette and text-scale variables into both adaptive stylesheets.
56 * Updating the active sheet last lets the visible app settle in one pass.
3657 */
37function applyTextScaleVariables(baseFontSize: number) {
38 const variables = resolveTextScaleVariables(baseFontSize);
58function applyAppearanceVariables(baseFontSize: number, themeIds: MobileThemeIds) {
59 const textVariables = resolveTextScaleVariables(baseFontSize);
3960 const currentTheme = Uniwind.currentTheme;
61 const activeAppearance =
62 currentTheme === "light" || currentTheme === "dark" ? currentTheme : null;
4063
4164 for (const theme of ["light", "dark"] as const) {
42 if (theme !== currentTheme) {
65 const variables = { ...getMobileThemeVariables(themeIds[theme], theme), ...textVariables };
66 if (theme !== activeAppearance) {
4367 Uniwind.updateCSSVariables(theme, variables);
4468 }
4569 }
46 Uniwind.updateCSSVariables(currentTheme, variables);
70 if (activeAppearance !== null) {
71 Uniwind.updateCSSVariables(activeAppearance, {
72 ...getMobileThemeVariables(themeIds[activeAppearance], activeAppearance),
73 ...textVariables,
74 });
75 }
4776}
4877
4978export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
5079 const preferencesResult = useAtomValue(mobilePreferencesAtom);
5180 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
81 const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light";
82 const storedPreferences = AsyncResult.isSuccess(preferencesResult)
83 ? preferencesResult.value
84 : null;
5285 const preferences = useMemo(
53 () =>
54 resolveAppearancePreferences(
55 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null,
56 ),
57 [preferencesResult],
86 () => resolveAppearancePreferences(storedPreferences),
87 [storedPreferences],
88 );
89 const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode);
90 const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode;
91 const themeIds = useMemo(
92 () => resolveMobileThemeIds(storedPreferences ?? {}),
93 [storedPreferences],
5894 );
95 const themeId = themeIds[themeAppearance];
5996 const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
6097
61 useEffect(() => {
62 applyTextScaleVariables(preferences.baseFontSize);
98 useLayoutEffect(() => {
99 applyAppearanceVariables(preferences.baseFontSize, themeIds);
100 Uniwind.setTheme(themeMode);
63101 cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize);
64 }, [preferences]);
102 }, [preferences, themeIds, themeMode]);
65103
66104 const updatePreferences = useCallback(
67 (patch: Partial<AppearancePreferences>) => {
105 (patch: Partial<Preferences>) => {
68106 savePreferences(patch);
69107 },
70108 [savePreferences],
71109 );
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
73134 const setBaseFontSize = useCallback(
74135 (value: number) => {
75136 updatePreferences({ baseFontSize: value });
......@@ -101,13 +162,34 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
101162 const value = useMemo(
102163 (): AppearancePreferencesContextValue => ({
103164 appearance: resolveAppearance(preferences),
165 themeId,
166 themeIds,
167 themeMode,
168 themeAppearance,
104169 isReady,
170 setThemeIdForAppearance,
171 setThemeIdForBothAppearances,
172 setThemeMode,
105173 setBaseFontSize,
106174 setTerminalFontSize,
107175 setCodeFontSize,
108176 setCodeWordBreak,
109177 }),
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 ],
111193 );
112194
113195 return (
apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx+5-11
......@@ -1,11 +1,4 @@
1import {
2 Platform,
3 ScrollView,
4 type StyleProp,
5 type TextStyle,
6 View,
7 useColorScheme,
8} from "react-native";
1import { Platform, ScrollView, type StyleProp, type TextStyle, View } from "react-native";
92
103import { AppText as Text } from "../../../../components/AppText";
114import {
......@@ -13,7 +6,8 @@ import {
136 resolveMobileCodeSurface,
147} from "../../../../lib/appearancePreferences";
158import { useThemeColor } from "../../../../lib/useThemeColor";
16import { getPierreTerminalTheme } from "../../../terminal/terminalTheme";
9import { getMobileTerminalTheme } from "../../../terminal/terminalTheme";
10import { useAppearancePreferences } from "../AppearancePreferencesProvider";
1711
1812const CODE_FONT_FAMILY = Platform.select({
1913 ios: "ui-monospace",
......@@ -53,8 +47,8 @@ export function TextAppearancePreview(props: { readonly fontSize: number }) {
5347 * on the shared card background so it reads like the other previews.
5448 */
5549export function TerminalAppearancePreview(props: { readonly fontSize: number }) {
56 const scheme = useColorScheme() === "light" ? "light" : "dark";
57 const theme = getPierreTerminalTheme(scheme);
50 const { themeAppearance: scheme, themeId } = useAppearancePreferences();
51 const theme = getMobileTerminalTheme(themeId, scheme);
5852 const lineHeight = Math.round(props.fontSize * 1.6);
5953 const lineStyle = {
6054 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 @@
11import type { ComponentProps } from "react";
2import { Switch, View } from "react-native";
2import { View } from "react-native";
33
44import { SymbolView } from "../../../components/AppSymbol";
55import { AppText as Text } from "../../../components/AppText";
6import { ThemedSwitch } from "../../../components/ThemedSwitch";
67import { useThemeColor } from "../../../lib/useThemeColor";
78
89type SymbolName = ComponentProps<typeof SymbolView>["name"];
......@@ -16,8 +17,6 @@ export function SettingsSwitchRow(props: {
1617 readonly onValueChange: (value: boolean) => void;
1718}) {
1819 const icon = useThemeColor("--color-icon");
19 const activeTrack = String(useThemeColor("--color-switch-active"));
20 const track = String(useThemeColor("--color-secondary-border"));
2120
2221 return (
2322 <View
......@@ -34,11 +33,9 @@ export function SettingsSwitchRow(props: {
3433 <Text className="text-sm text-foreground-muted">{props.subtitle}</Text>
3534 ) : null}
3635 </View>
37 <Switch
36 <ThemedSwitch
3837 disabled={props.disabled}
39 ios_backgroundColor={track}
4038 onValueChange={props.onValueChange}
41 trackColor={{ false: track, true: activeTrack }}
4239 value={props.value}
4340 />
4441 </View>
apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx+65-14
......@@ -1,4 +1,4 @@
1import { useEffect, useRef, useState } from "react";
1import { useEffect, useRef, useState, useSyncExternalStore } from "react";
22import { Keyboard, View } from "react-native";
33import {
44 CommonActions,
......@@ -10,6 +10,8 @@ import {
1010import { AsyncResult } from "effect/unstable/reactivity";
1111
1212import { useConnectionController } from "../connection/useConnectionController";
13import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
14import type { MobileThemeId } from "../../lib/mobileTheme";
1315import { useProjects, useThreadShells } from "../../state/entities";
1416import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
1517import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";
......@@ -19,6 +21,7 @@ import {
1921 getNativeShowcaseOrientation,
2022 getNativeShowcasePairingUrls,
2123 getNativeShowcaseScene,
24 getNativeShowcaseTheme,
2225 markNativeShowcaseReady,
2326 type ShowcaseScene,
2427} from "./nativeShowcaseScene";
......@@ -27,6 +30,12 @@ import {
2730 SHOWCASE_PENDING_TASK_DEFINITIONS,
2831} from "./showcasePendingTasks";
2932import { retryShowcaseOperation } from "./showcaseRetry";
33import {
34 clearShowcaseRenderSignal,
35 getShowcaseRenderSignal,
36 isShowcaseNativeContentReady,
37 subscribeToShowcaseRenderSignal,
38} from "./showcaseRenderSignal";
3039
3140const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
3241const SHOWCASE_THREAD_ID = "remote-command-center";
......@@ -48,6 +57,12 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null {
4857export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) {
4958 const navigation = useNavigation();
5059 const { connectPairingUrl } = useConnectionController();
60 const {
61 isReady: appearancePreferencesReady,
62 themeId,
63 themeIds,
64 setThemeIdForBothAppearances,
65 } = useAppearancePreferences();
5166 const workspace = useWorkspaceState();
5267 const projects = useProjects();
5368 const threads = useThreadShells();
......@@ -56,18 +71,32 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
5671 const [pairingUrls, setPairingUrls] = useState<ReadonlyArray<string>>([]);
5772 const [pendingTasksReady, setPendingTasksReady] = useState(false);
5873 const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null);
74 const [requestedTheme, setRequestedTheme] = useState<MobileThemeId | null>(null);
75 const [themeRequestSettled, setThemeRequestSettled] = useState(false);
5976 const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);
6077 const [orientationSettled, setOrientationSettled] = useState(false);
78 const requestedSceneRef = useRef<ShowcaseScene | null>(null);
79 const renderSignal = useSyncExternalStore(
80 subscribeToShowcaseRenderSignal,
81 getShowcaseRenderSignal,
82 getShowcaseRenderSignal,
83 );
6184
6285 useEffect(() => {
6386 if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return;
6487
65 const readPairingUrls = () => {
88 const readLaunchRequest = () => {
6689 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);
6897 };
69 readPairingUrls();
70 const interval = setInterval(readPairingUrls, 250);
98 readLaunchRequest();
99 const interval = setInterval(readLaunchRequest, 250);
71100 return () => clearInterval(interval);
72101 }, [pairingUrls.length]);
73102
......@@ -95,13 +124,38 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
95124
96125 const readRequestedScene = () => {
97126 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);
99133 };
100134 readRequestedScene();
101135 const interval = setInterval(readRequestedScene, 250);
102136 return () => clearInterval(interval);
103137 }, []);
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
105159 useEffect(() => {
106160 if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return;
107161 let cancelled = false;
......@@ -219,17 +273,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
219273 !hasFixture ||
220274 // Never report a scene ready while the capture orientation is still
221275 // being applied — a screenshot taken early has the wrong dimensions.
222 !orientationSettled
276 !orientationSettled ||
277 // Likewise for the palette: an early screenshot shows the default theme.
278 !themeApplied ||
279 !isShowcaseNativeContentReady({ scene, themeId, renderSignal })
223280 ) {
224281 setReadyScene(null);
225282 return;
226283 }
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 }
233284 if (scene === "terminal") Keyboard.dismiss();
234285
235286 let renderFrame: number | null = null;
......@@ -247,7 +298,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
247298 if (renderFrame !== null) cancelAnimationFrame(renderFrame);
248299 if (readyFrame !== null) cancelAnimationFrame(readyFrame);
249300 };
250 }, [hasFixture, orientationSettled, requestedScene, scene]);
301 }, [hasFixture, orientationSettled, renderSignal, requestedScene, scene, themeApplied, themeId]);
251302
252303 if (!SHOWCASE_ENABLED || readyScene === null) return null;
253304
apps/mobile/src/features/showcase/nativeShowcaseScene.ts+17
......@@ -1,5 +1,7 @@
11import { requireOptionalNativeModule } from "expo";
22
3import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme";
4
35export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;
46export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];
57
......@@ -8,6 +10,7 @@ export type ShowcaseOrientation = "portrait" | "landscape";
810interface NativeShowcaseControls {
911 readonly getShowcasePairingUrl?: () => string | null;
1012 readonly getShowcaseScene?: () => string | null;
13 readonly getShowcaseTheme?: () => string | null;
1114 readonly getShowcaseOrientation?: () => string | null;
1215 readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise<void>;
1316 readonly getInterfaceOrientation?: () => Promise<string>;
......@@ -56,6 +59,20 @@ export function getNativeShowcaseScene(): ShowcaseScene | null {
5659 }
5760}
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
5976export function prepareNativeShowcaseCapture(): void {
6077 try {
6178 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 {
77 type LayoutChangeEvent,
88 type NativeSyntheticEvent,
99 type ViewProps,
10 useColorScheme,
1110} from "react-native";
1211
1312import { AppText as Text } from "../../components/AppText";
1413import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
14import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1515import {
1616 getNativeTerminalHardwareKeyRevision,
1717 resolveNativeTerminalSurfaceView,
1818} from "./nativeTerminalModule";
1919import {
2020 buildGhosttyThemeConfig,
21 getPierreTerminalTheme,
21 getMobileTerminalTheme,
2222 type TerminalTheme,
2323} from "./terminalTheme";
2424import { terminalDebugLog } from "./terminalDebugLog";
......@@ -60,8 +60,8 @@ function estimateGridSize(input: {
6060const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {
6161 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
6262 const inputRef = useRef<TextInput>(null);
63 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
64 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
63 const { themeAppearance, themeId } = useAppearancePreferences();
64 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
6565 const statusLabel = props.isRunning
6666 ? "Native terminal unavailable. Using text fallback."
6767 : "Open terminal to start a shell.";
......@@ -173,8 +173,8 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
173173
174174export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
175175 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
176 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
177 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
176 const { themeAppearance, themeId } = useAppearancePreferences();
177 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
178178 const { onInput, onResize } = props;
179179 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
180180 const hasNativeSurface = Boolean(NativeTerminalSurfaceView);
......@@ -215,7 +215,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
215215 return (
216216 <View style={props.style}>
217217 <NativeTerminalSurfaceView
218 appearanceScheme={appearanceScheme}
218 appearanceScheme={themeAppearance}
219219 autoFocus={props.autoFocus ?? true}
220220 backgroundColor={theme.background}
221221 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";
44import { Pressable, View } from "react-native";
55
66import { AppText as Text } from "../../components/AppText";
7import { useThemeColor } from "../../lib/useThemeColor";
78import { terminalEnvironment } from "../../state/terminal";
89import { useAtomCommand } from "../../state/use-atom-command";
910import { useAttachedTerminalSession } from "../../state/use-terminal-session";
......@@ -35,6 +36,7 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
3536 const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");
3637 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
3738 const nativeTerminalAvailable = hasNativeTerminalSurface();
39 const iconColor = useThemeColor("--color-icon");
3840 const terminalId = DEFAULT_TERMINAL_ID;
3941 const lastGridSizeRef = useRef<TerminalGridSize>({
4042 cols: DEFAULT_TERMINAL_COLS,
......@@ -214,13 +216,13 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
214216 }
215217
216218 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">
218 <View className="flex-row items-center justify-between border-b border-white/10 px-3 py-2">
219 <View className="absolute inset-x-3 bottom-28 top-28 overflow-hidden rounded-[8px] border border-border bg-screen shadow-2xl">
220 <View className="flex-row items-center justify-between border-b border-border px-3 py-2">
219221 <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}>
221223 Terminal
222224 </Text>
223 <Text className="text-2xs text-neutral-500" numberOfLines={1}>
225 <Text className="text-2xs text-foreground-muted" numberOfLines={1}>
224226 {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"}
225227 </Text>
226228 </View>
......@@ -231,10 +233,10 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
231233 </Text>
232234 ) : null}
233235 <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"
235237 onPress={props.onClose}
236238 >
237 <SymbolView name="xmark" size={13} tintColor="#e5e5e5" type="monochrome" />
239 <SymbolView name="xmark" size={13} tintColor={iconColor} type="monochrome" />
238240 </Pressable>
239241 </View>
240242 </View>
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+6-4
......@@ -5,7 +5,7 @@ import { SymbolView } from "../../components/AppSymbol";
55import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
66import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
77import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8import { Platform, Pressable, View, useColorScheme } from "react-native";
8import { Platform, Pressable, View } from "react-native";
99import {
1010 KeyboardController,
1111 KeyboardEvents,
......@@ -44,7 +44,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail";
4444import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
4545import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
4646import { TerminalSurface } from "./NativeTerminalSurface";
47import { getPierreTerminalTheme } from "./terminalTheme";
47import { getMobileTerminalTheme } from "./terminalTheme";
4848import { terminalDebugLog } from "./terminalDebugLog";
4949import {
5050 getTerminalBufferReplayKey,
......@@ -166,7 +166,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
166166 const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");
167167 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
168168 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
169 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
170169 const { state: workspaceState } = useWorkspaceState();
171170 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
172171 const params = props.route.params;
......@@ -186,6 +185,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
186185 const {
187186 isReady: hasResolvedFontPreference,
188187 appearance,
188 themeAppearance: appearanceScheme,
189 themeId,
189190 setTerminalFontSize,
190191 } = useAppearancePreferences();
191192 const fontSize = appearance.terminalFontSize;
......@@ -466,7 +467,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
466467 [selectedEnvironmentConnection?.environmentLabel],
467468 );
468469
469 const terminalTheme = getPierreTerminalTheme(appearanceScheme);
470 const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme);
470471 const usesNativeHeaderGlass = Platform.OS === "ios";
471472 const pendingModifier =
472473 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
......@@ -1228,6 +1229,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
12281229 onResize={handleResize}
12291230 style={{ flex: 1 }}
12301231 terminalKey={terminalKey}
1232 theme={terminalTheme}
12311233 />
12321234 </View>
12331235
apps/mobile/src/features/terminal/terminalTheme.test.ts+35-1
......@@ -1,6 +1,13 @@
11import { 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
512describe("getPierreTerminalTheme", () => {
613 it("returns the Pierre light terminal palette", () => {
......@@ -22,6 +29,33 @@ describe("getPierreTerminalTheme", () => {
2229 });
2330});
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
2559describe("buildGhosttyThemeConfig", () => {
2660 it("serializes theme colors into a ghostty config file", () => {
2761 const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark"));
apps/mobile/src/features/terminal/terminalTheme.ts+30
......@@ -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
19export type TerminalAppearanceScheme = "light" | "dark";
210
311export interface TerminalTheme {
......@@ -70,6 +78,28 @@ export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): Termin
7078 return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME;
7179}
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
73103export function buildGhosttyThemeConfig(theme: TerminalTheme): string {
74104 const lines = [
75105 `background = ${theme.background}`,
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+10-16
......@@ -2,11 +2,12 @@ import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
22import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
33import { SymbolView } from "../../components/AppSymbol";
44import { memo } from "react";
5import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native";
5import { Pressable, ScrollView, View, type ViewStyle } from "react-native";
66
77import { AppText as Text } from "../../components/AppText";
88import { GlassSurface } from "../../components/GlassSurface";
99import { PierreEntryIcon } from "../../components/PierreEntryIcon";
10import { useThemeColor } from "../../lib/useThemeColor";
1011export type ComposerCommandItem =
1112 | {
1213 readonly id: string;
......@@ -45,11 +46,8 @@ interface ComposerCommandPopoverProps {
4546 readonly onSelect: (item: ComposerCommandItem) => void;
4647}
4748
48function PopoverSurface(props: {
49 readonly children: React.ReactNode;
50 readonly isDarkMode: boolean;
51 readonly style?: ViewStyle;
52}) {
49function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) {
50 const tintColor = useThemeColor("--color-glass-surface");
5351 const baseStyle: ViewStyle = {
5452 borderRadius: 16,
5553 overflow: "hidden",
......@@ -57,11 +55,7 @@ function PopoverSurface(props: {
5755 };
5856
5957 return (
60 <GlassSurface
61 glassEffectStyle="clear"
62 tintColor={props.isDarkMode ? "rgba(30,30,32,0.95)" : "rgba(255,255,255,0.92)"}
63 style={baseStyle}
64 >
58 <GlassSurface glassEffectStyle="clear" tintColor={tintColor} style={baseStyle}>
6559 {props.children}
6660 </GlassSurface>
6761 );
......@@ -114,7 +108,8 @@ const CommandRow = memo(function CommandRow(props: {
114108 readonly isLast: boolean;
115109}) {
116110 const iconName = itemIcon(props.item);
117 const iconColor = "#a1a1aa";
111 const iconColor = useThemeColor("--color-icon-subtle");
112 const borderColor = useThemeColor("--color-border");
118113
119114 return (
120115 <Pressable
......@@ -127,7 +122,7 @@ const CommandRow = memo(function CommandRow(props: {
127122 gap: 10,
128123 opacity: pressed ? 0.6 : 1,
129124 borderBottomWidth: props.isLast ? 0 : 0.5,
130 borderBottomColor: "rgba(255,255,255,0.1)",
125 borderBottomColor: borderColor,
131126 })}
132127 >
133128 {props.item.type === "path" ? (
......@@ -139,7 +134,7 @@ const CommandRow = memo(function CommandRow(props: {
139134 {props.item.label}
140135 </Text>
141136 {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}>
143138 {props.item.description}
144139 </Text>
145140 ) : null}
......@@ -150,11 +145,10 @@ const CommandRow = memo(function CommandRow(props: {
150145export const ComposerCommandPopover = memo(function ComposerCommandPopover(
151146 props: ComposerCommandPopoverProps,
152147) {
153 const isDarkMode = useColorScheme() === "dark";
154148 const label = groupLabel(props.triggerKind);
155149
156150 return (
157 <PopoverSurface isDarkMode={isDarkMode}>
151 <PopoverSurface>
158152 {label ? (
159153 <View className="px-3.5 pt-2.5 pb-1">
160154 <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";
22import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
33import { SymbolView } from "../../components/AppSymbol";
44import { useCallback, useEffect, useRef } from "react";
5import { ActivityIndicator, Pressable, StyleSheet, useColorScheme, View } from "react-native";
5import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native";
66import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
77import { useSafeAreaInsets } from "react-native-safe-area-context";
88
......@@ -11,6 +11,7 @@ import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics";
1111import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
1212import { useThemeColor } from "../../lib/useThemeColor";
1313import type { GitActionProgress } from "../../state/use-vcs-action-state";
14import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1415
1516const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220);
1617const OVERLAY_TOP_GAP = 8;
......@@ -69,7 +70,8 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) {
6970 const iconColor = useThemeColor("--color-icon");
7071 const glassBorder = useThemeColor("--color-header-border");
7172 const glassTint = useThemeColor("--color-glass-tint");
72 const isDarkMode = useColorScheme() === "dark";
73 const { themeAppearance } = useAppearancePreferences();
74 const isDarkMode = themeAppearance === "dark";
7375 const content = (
7476 <>
7577 <OverlayIcon phase={progress.phase} iconColor={iconColor} />
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+2-2
......@@ -13,7 +13,6 @@ import {
1313 Platform,
1414 Pressable,
1515 ScrollView,
16 Switch,
1716 TextInput,
1817 View,
1918} from "react-native";
......@@ -22,6 +21,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
2221import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
2322import { SymbolView } from "../../components/AppSymbol";
2423import { AppText as Text } from "../../components/AppText";
24import { ThemedSwitch } from "../../components/ThemedSwitch";
2525import { cn } from "../../lib/cn";
2626import { useFontFamily } from "../../lib/useFontFamily";
2727import { useThemeColor } from "../../lib/useThemeColor";
......@@ -97,7 +97,7 @@ function ToggleRow(props: {
9797 <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}>
9898 {props.title}
9999 </Text>
100 <Switch
100 <ThemedSwitch
101101 accessibilityLabel={props.title}
102102 onValueChange={props.onValueChange}
103103 value={props.value}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+7-4
......@@ -6,7 +6,7 @@ import {
66 usePreventRemove,
77} from "@react-navigation/native";
88import { 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";
1010import {
1111 KeyboardController,
1212 KeyboardStickyView,
......@@ -14,6 +14,7 @@ import {
1414} from "react-native-keyboard-controller";
1515import { useSafeAreaInsets } from "react-native-safe-area-context";
1616import { useThemeColor } from "../../lib/useThemeColor";
17import { themeColorWithAlpha } from "../../lib/mobileTheme";
1718import { useFontFamily } from "../../lib/useFontFamily";
1819
1920import {
......@@ -42,6 +43,7 @@ import {
4243import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
4344import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
4445import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
46import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
4547import {
4648 clearComposerDraftContent,
4749 getComposerDraftSnapshot,
......@@ -107,7 +109,7 @@ export function NewTaskDraftScreen(props: {
107109 reserveShare,
108110 } = useIncomingShare();
109111 const insets = useSafeAreaInsets();
110 const colorScheme = useColorScheme();
112 const { themeAppearance: colorScheme } = useAppearancePreferences();
111113 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
112114 const controlsBottomPadding = Math.max(insets.bottom, 10);
113115 const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8);
......@@ -290,11 +292,12 @@ export function NewTaskDraftScreen(props: {
290292 }, [props.pendingTaskId, cancelEditingPendingTask]);
291293
292294 const foregroundColor = useThemeColor("--color-foreground");
295 const sheetColor = String(useThemeColor("--color-sheet"));
293296 const projectUnderlineColor = useThemeColor("--color-foreground-muted");
294297 const regularFontFamily = useFontFamily("regular");
295298 const bodyText = useScaledTextRole("body");
296 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
297 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
299 const sheetFadeOpaque = sheetColor;
300 const sheetFadeTransparent = themeColorWithAlpha(sheetColor, 0);
298301
299302 // A new navigation to this mounted screen delivers a fresh initialProjectRef
300303 // 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 {
2222 Platform,
2323 Pressable,
2424 StyleSheet,
25 useColorScheme,
2625 View,
2726 type ViewStyle,
2827} from "react-native";
......@@ -35,6 +34,7 @@ import Animated, {
3534 LinearTransition,
3635} from "react-native-reanimated";
3736import { useThemeColor } from "../../lib/useThemeColor";
37import { themeColorWithAlpha } from "../../lib/mobileTheme";
3838import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
3939import { scopedThreadKey } from "../../lib/scopedEntities";
4040
......@@ -57,6 +57,7 @@ import { ProviderIcon } from "../../components/ProviderIcon";
5757import type { DraftComposerImageAttachment } from "../../lib/composerImages";
5858import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
5959import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
60import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
6061import type { RemoteClientConnectionState } from "../../lib/connection";
6162import {
6263 insertRankedSearchResult,
......@@ -144,11 +145,14 @@ export function ComposerSurface(props: {
144145 /** Existing thread composers morph between pill and card layouts. */
145146 readonly animateLayout?: boolean;
146147}) {
148 const cardColor = useThemeColor("--color-card-translucent");
149 const borderColor = useThemeColor("--color-border");
150 const shadowColor = useThemeColor("--color-primary-shadow");
147151 // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself
148152 // (needed to clip content to the pill shape) would clip the shadow on iOS.
149153 const shadowStyle: ViewStyle = {
150154 borderRadius: props.style.borderRadius,
151 shadowColor: "#000000",
155 shadowColor,
152156 shadowOpacity: props.isDarkMode ? 0.35 : 0.12,
153157 shadowRadius: 14,
154158 shadowOffset: { width: 0, height: 6 },
......@@ -163,9 +167,9 @@ export function ComposerSurface(props: {
163167 <GlassSurface
164168 chrome="none"
165169 fallbackStyle={{
166 backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)",
170 backgroundColor: cardColor,
167171 borderWidth: 1,
168 borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)",
172 borderColor,
169173 }}
170174 glassEffectStyle="regular"
171175 // The composer is a passive material containing interactive controls.
......@@ -235,6 +239,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
235239 readonly status: ComposerStatusPillState;
236240}) {
237241 const isReconnecting = props.status.kind !== "unavailable";
242 const indicatorColor = useThemeColor("--color-icon-muted");
238243
239244 return (
240245 <Animated.View
......@@ -246,10 +251,10 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
246251 <Pressable
247252 accessibilityRole="button"
248253 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"
250255 >
251256 {isReconnecting ? (
252 <ActivityIndicator size="small" color="#8e8e93" />
257 <ActivityIndicator size="small" color={indicatorColor} />
253258 ) : (
254259 <View className="h-2 w-2 rounded-full bg-red-500" />
255260 )}
......@@ -266,7 +271,8 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
266271
267272export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
268273 const navigation = useNavigation();
269 const isDarkMode = useColorScheme() === "dark";
274 const { themeAppearance } = useAppearancePreferences();
275 const isDarkMode = themeAppearance === "dark";
270276 const foregroundColor = useThemeColor("--color-foreground");
271277 const bodyText = useScaledTextRole("body");
272278 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
......@@ -335,8 +341,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
335341 environmentLabel: props.environmentLabel,
336342 threadSyncPhase: props.threadSyncPhase,
337343 });
338 const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";
339 const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)";
344 const toolbarSurface = String(useThemeColor("--color-card"));
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%)`;
340349 const selectedProviderStatus = useMemo(() => {
341350 if (!props.serverConfig) return null;
342351 return (
......@@ -704,9 +713,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
704713 style={[
705714 StyleSheet.absoluteFill,
706715 {
707 experimental_backgroundImage: isDarkMode
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%)",
716 experimental_backgroundImage: backdropGradient,
710717 },
711718 ]}
712719 />
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+3-2
......@@ -32,7 +32,6 @@ import {
3232 AppState,
3333 Keyboard,
3434 Platform,
35 useColorScheme,
3635 useWindowDimensions,
3736 View,
3837 type GestureResponderEvent,
......@@ -53,6 +52,7 @@ import Animated, {
5352import { useSafeAreaInsets } from "react-native-safe-area-context";
5453
5554import { ControlPill } from "../../components/ControlPill";
55import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
5656import type { ComposerEditorHandle } from "../../components/ComposerEditor";
5757import type { StatusTone } from "../../components/StatusPill";
5858import type { DraftComposerImageAttachment } from "../../lib/composerImages";
......@@ -539,7 +539,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
539539 }, [freeze, scrollMessageToEnd]);
540540
541541 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
542 const isDarkMode = useColorScheme() === "dark";
542 const { themeAppearance } = useAppearancePreferences();
543 const isDarkMode = themeAppearance === "dark";
543544
544545 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
545546 feedTouchStartRef.current = {
apps/mobile/src/features/threads/ThreadFeed.tsx+45-73
......@@ -37,7 +37,6 @@ import {
3737 StyleSheet,
3838 Text as NativeText,
3939 type ColorValue,
40 useColorScheme,
4140 useWindowDimensions,
4241 View,
4342} from "react-native";
......@@ -194,43 +193,6 @@ function MessageAttachmentImage(props: {
194193 );
195194}
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
234196const MARKDOWN_MONO_FONT = Platform.select({
235197 ios: "ui-monospace",
236198 android: "monospace",
......@@ -426,14 +388,12 @@ function MarkdownCodeBlock(props: {
426388}
427389
428390function useReviewCommentColors(): ReviewCommentColors {
429 const colorScheme = useColorScheme();
430 const isDark = colorScheme === "dark";
431 const background = isDark ? "#151515" : "#ffffff";
432 const border = isDark ? "#2a2a2a" : "#d7d7d7";
433 const mutedBackground = isDark ? "#242424" : "#f2f2f2";
434 const text = isDark ? "#f3f3f3" : "#111111";
435 const mutedText = isDark ? "#8f8f8f" : "#666666";
436 const codeBackground = isDark ? "#0f0f0f" : "#ffffff";
391 const background = useThemeColor("--color-card");
392 const border = useThemeColor("--color-border");
393 const mutedBackground = useThemeColor("--color-subtle");
394 const text = useThemeColor("--color-foreground");
395 const mutedText = useThemeColor("--color-foreground-muted");
396 const codeBackground = useThemeColor("--color-md-code-bg");
437397
438398 return useMemo(
439399 () => ({
......@@ -449,8 +409,7 @@ function useReviewCommentColors(): ReviewCommentColors {
449409}
450410
451411function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {
452 const colorScheme = useColorScheme();
453 const { appearance } = useAppearancePreferences();
412 const { appearance, themeAppearance } = useAppearancePreferences();
454413 const markdownFontSizes = useMemo(
455414 () => resolveMarkdownFontSizes(appearance.baseFontSize),
456415 [appearance.baseFontSize],
......@@ -459,31 +418,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
459418 () => resolveNativeMarkdownTypography(appearance.baseFontSize),
460419 [appearance.baseFontSize],
461420 );
462 const themeMode = colorScheme === "dark" ? "dark" : "light";
463 const colors = MARKDOWN_COLORS[themeMode];
421 const themeMode = themeAppearance;
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"));
464437 const iconSubtleColor = String(useThemeColor("--color-icon-subtle"));
465438 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
439 const userBubbleSkillForeground = String(useThemeColor("--color-user-bubble-skill-foreground"));
466440 const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted"));
467441 const regularFontFamily = useFontFamily("regular");
468442 const boldFontFamily = useFontFamily("bold");
469443
470444 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
487445 const baseTheme: PartialMarkdownTheme = {
488446 colors: {
489447 text: markdownBodyColor,
......@@ -759,8 +717,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
759717 codeColor: markdownUserCodeText,
760718 codeBackgroundColor: markdownUserCodeBg,
761719 codeBlockBackgroundColor: markdownUserFenceBg,
762 fileTextColor: "#ffffff",
763 skillTextColor: "#f0abfc",
720 fileTextColor: markdownUserBodyColor,
721 skillTextColor: userBubbleSkillForeground,
764722 quoteMarkerColor: markdownUserBodyColor,
765723 dividerColor: markdownUserBodyColor,
766724 fontSize: nativeMarkdownTypography.fontSize,
......@@ -807,15 +765,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
807765 };
808766 }, [
809767 boldFontFamily,
810 colors,
811768 iconSubtleColor,
812769 inlineSkillForeground,
770 markdownBlockquoteBg,
771 markdownBlockquoteBorder,
772 markdownBodyColor,
773 markdownCodeBg,
774 markdownCodeText,
813775 markdownFontSizes,
776 markdownHrColor,
777 markdownInlineCodeText,
778 markdownLinkColor,
779 markdownStrongColor,
780 markdownUserBodyColor,
781 markdownUserCodeBg,
782 markdownUserCodeText,
783 markdownUserFenceBg,
784 markdownUserFenceText,
785 markdownUserInlineCodeText,
814786 nativeMarkdownTypography,
815787 onLinkPress,
816788 regularFontFamily,
817789 themeMode,
818790 userBubbleForegroundMuted,
791 userBubbleSkillForeground,
819792 ]);
820793}
821794
......@@ -1141,8 +1114,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
11411114 readonly colors: ReviewCommentColors;
11421115}) {
11431116 const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface();
1144 const colorScheme = useColorScheme();
1145 const appearanceScheme = colorScheme === "light" ? "light" : "dark";
1117 const { themeAppearance: appearanceScheme, themeId } = useAppearancePreferences();
11461118 const NativeReviewDiffView = resolveNativeReviewDiffView();
11471119 const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]);
11481120 const parsedDiff = useMemo(
......@@ -1155,8 +1127,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
11551127 [nativeReviewDiffData.rows],
11561128 );
11571129 const nativeReviewDiffTheme = useMemo(
1158 () => createNativeReviewDiffTheme(appearanceScheme),
1159 [appearanceScheme],
1130 () => createNativeReviewDiffTheme(appearanceScheme, themeId),
1131 [appearanceScheme, themeId],
11601132 );
11611133 const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]);
11621134 const nativeThemeJson = useMemo(
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+6-5
......@@ -15,7 +15,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
1515import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1616import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
1717import 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";
1919import { Gesture, GestureDetector } from "react-native-gesture-handler";
2020import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2121import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -38,6 +38,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
3838import { useWorkspaceState } from "../../state/workspace";
3939import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
4040import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
41import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
4142import {
4243 hasCustomHomeListOptions,
4344 PROJECT_SORT_OPTIONS,
......@@ -102,6 +103,8 @@ function SidebarHeaderButtonGroup(props: {
102103 readonly children: ReactNode;
103104 readonly colorScheme: "light" | "dark";
104105}) {
106 const fallbackBackground = useThemeColor("--color-glass-surface");
107 const fallbackBorder = useThemeColor("--color-header-border");
105108 if (isLiquidGlassSupported) {
106109 return (
107110 <LiquidGlassView
......@@ -119,9 +122,7 @@ function SidebarHeaderButtonGroup(props: {
119122 <View
120123 style={[
121124 styles.headerButtonGroup,
122 props.colorScheme === "dark"
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 { backgroundColor: fallbackBackground, borderColor: fallbackBorder },
125126 { borderWidth: StyleSheet.hairlineWidth },
126127 ]}
127128 >
......@@ -192,7 +193,7 @@ function ThreadNavigationSidebarPane(
192193 props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },
193194) {
194195 const insets = useSafeAreaInsets();
195 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
196 const { themeAppearance: colorScheme } = useAppearancePreferences();
196197 const projects = useProjects();
197198 const threads = useThreadShells();
198199 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+6-14
......@@ -27,7 +27,7 @@ import {
2727 useState,
2828 type ReactNode,
2929} from "react";
30import { Platform, Pressable, ScrollView, Switch, TextInput, View } from "react-native";
30import { Platform, Pressable, ScrollView, TextInput, View } from "react-native";
3131import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
3232import { useSafeAreaInsets } from "react-native-safe-area-context";
3333
......@@ -35,6 +35,7 @@ import { SymbolView } from "../../components/AppSymbol";
3535import { AppText as Text } from "../../components/AppText";
3636import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
3737import { ProviderIcon } from "../../components/ProviderIcon";
38import { ThemedSwitch } from "../../components/ThemedSwitch";
3839import { cn } from "../../lib/cn";
3940import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
4041import { applyProviderOptionSelection } from "../../lib/providerOptions";
......@@ -46,10 +47,6 @@ import {
4647 nativeHeaderScrollEdgeEffects,
4748} from "../../native/StackHeader";
4849import { 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";
5350import { useNewTaskFlow } from "./new-task-flow-provider";
5451import {
5552 createNativeMailSearchToolbarItem,
......@@ -270,7 +267,7 @@ function SwitchRow(props: {
270267 )}
271268 >
272269 <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text>
273 <Switch
270 <ThemedSwitch
274271 accessibilityLabel={props.label}
275272 onValueChange={props.onValueChange}
276273 value={props.value}
......@@ -1128,9 +1125,8 @@ function ThreadSettingsChoiceScreen() {
11281125}
11291126
11301127function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) {
1131 const sheetBackground = String(useThemeColor("--color-sheet"));
1128 const solidSheetBackground = String(useThemeColor("--color-sheet-solid"));
11321129 const foreground = String(useThemeColor("--color-foreground"));
1133 const nativeSheetBackground = NATIVE_SHEET_SURFACE_COLOR ?? sheetBackground;
11341130 const presentation = useMemo(
11351131 () => ({
11361132 onClose: props.onClose,
......@@ -1144,17 +1140,13 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)
11441140 initialRouteName="ThreadSettingsModels"
11451141 screenOptions={{
11461142 animation: "slide_from_right",
1147 contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE ?? {
1148 backgroundColor: nativeSheetBackground,
1149 },
1143 contentStyle: { backgroundColor: solidSheetBackground },
11501144 gestureEnabled: true,
11511145 headerBackButtonDisplayMode: "minimal",
11521146 headerBackTitle: "",
11531147 headerShadowVisible: false,
11541148 headerStyle: {
1155 backgroundColor: (NATIVE_LIQUID_GLASS_SUPPORTED
1156 ? "transparent"
1157 : nativeSheetBackground) as unknown as string,
1149 backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground,
11581150 },
11591151 headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
11601152 headerTintColor: foreground,
apps/mobile/src/features/threads/sidebar-filter-button.tsx+3-5
......@@ -1,5 +1,5 @@
11import { SymbolView } from "../../components/AppSymbol";
2import { Pressable, StyleSheet, useColorScheme } from "react-native";
2import { Pressable, StyleSheet } from "react-native";
33
44import { useThemeColor } from "../../lib/useThemeColor";
55
......@@ -15,10 +15,8 @@ export function SidebarFilterButton(props: {
1515}) {
1616 const iconColor = useThemeColor("--color-foreground");
1717 const pressedBackgroundColor = useThemeColor("--color-subtle");
18 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
19 const idleBackgroundColor =
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)";
18 const idleBackgroundColor = useThemeColor("--color-glass-surface");
19 const borderColor = useThemeColor("--color-header-border");
2220
2321 return (
2422 <Pressable
apps/mobile/src/features/threads/sidebar-header-actions.tsx+3-5
......@@ -1,5 +1,5 @@
11import { SymbolView } from "../../components/AppSymbol";
2import { Pressable, StyleSheet, View, useColorScheme } from "react-native";
2import { Pressable, StyleSheet, View } from "react-native";
33
44import { useThemeColor } from "../../lib/useThemeColor";
55
......@@ -17,10 +17,8 @@ function FallbackHeaderButton(props: {
1717}) {
1818 const iconColor = useThemeColor("--color-foreground");
1919 const pressedBackgroundColor = useThemeColor("--color-subtle");
20 const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
21 const idleBackgroundColor =
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)";
20 const idleBackgroundColor = useThemeColor("--color-glass-surface");
21 const borderColor = useThemeColor("--color-header-border");
2422
2523 return (
2624 <Pressable
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+7-9
......@@ -1,19 +1,16 @@
1import {
2 DarkTheme,
3 DefaultTheme,
4 NavigationContainer,
5 NavigationIndependentTree,
6} from "@react-navigation/native";
1import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native";
72import {
83 createNativeStackNavigator,
94 type NativeStackNavigationOptions,
105} from "@react-navigation/native-stack";
116import type { ReactNode } from "react";
12import { Platform, useColorScheme } from "react-native";
7import { Platform } from "react-native";
138
149import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle";
1510import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
1611import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
12import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme";
13import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1714
1815const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
1916
......@@ -56,11 +53,12 @@ const SidebarStack = createNativeStackNavigator();
5653 * navigation hooks used for header configuration inside the pane.
5754 */
5855export function SidebarNavigationShell(props: { readonly children: ReactNode }) {
59 const colorScheme = useColorScheme();
56 const { themeAppearance } = useAppearancePreferences();
57 const navigationTheme = useMobileNavigationTheme(themeAppearance);
6058
6159 return (
6260 <NavigationIndependentTree>
63 <NavigationContainer theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
61 <NavigationContainer theme={navigationTheme}>
6462 <SidebarStack.Navigator
6563 screenOptions={SIDEBAR_SCREEN_OPTIONS}
6664 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
77import type { MenuAction } from "@react-native-menu/menu";
88import { SymbolView } from "../../components/AppSymbol";
99import { memo, useCallback, useMemo, type ComponentProps } from "react";
10import { Pressable, useColorScheme, useWindowDimensions, View } from "react-native";
10import { Pressable, useWindowDimensions, View } from "react-native";
1111import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
12import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1213import Svg, { Circle, Path } from "react-native-svg";
1314
1415import { AppText as Text } from "../../components/AppText";
......@@ -17,6 +18,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
1718import { cn } from "../../lib/cn";
1819import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
1920import { relativeTime } from "../../lib/time";
21import { themeColorWithAlpha } from "../../lib/mobileTheme";
2022import { useThemeColor } from "../../lib/useThemeColor";
2123import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2224import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";
......@@ -38,10 +40,7 @@ export type ThreadListVariant = "compact" | "sidebar";
3840export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;
3941const SIDEBAR_ROW_RADIUS = 12;
4042
41function pullRequestTintColor(
42 state: ThreadPr["state"],
43 colorScheme: ReturnType<typeof useColorScheme>,
44) {
43function pullRequestTintColor(state: ThreadPr["state"], colorScheme: "light" | "dark") {
4544 const dark = colorScheme === "dark";
4645 switch (state) {
4746 case "open":
......@@ -440,7 +439,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
440439 >["simultaneousWithExternalGesture"];
441440}) {
442441 const { width: windowWidth } = useWindowDimensions();
443 const colorScheme = useColorScheme();
442 const { themeAppearance: colorScheme } = useAppearancePreferences();
444443 const compact = props.variant === "compact";
445444 const selected = props.selected === true;
446445 // Recycling-safe: resets when the list container is reused for another
......@@ -453,6 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
453452 const drawerColor = useThemeColor("--color-drawer");
454453 const pressedBackgroundColor = useThemeColor("--color-subtle");
455454 const selectedBackgroundColor = useThemeColor("--color-user-bubble");
455 const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground");
456456
457457 const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
458458 props;
......@@ -467,10 +467,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
467467 );
468468
469469 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;
471473 const effectiveStatus =
472474 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 }
474480 : status;
475481
476482 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
......@@ -538,11 +544,15 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
538544 <View className="flex-row items-center gap-0.5">
539545 <PullRequestIcon
540546 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 }
542552 />
543553 <Text
544554 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${
545 selected ? "text-white" : pr.textClassName
555 selected ? "text-user-bubble-foreground" : pr.textClassName
546556 }`}
547557 >
548558 {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
66import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
77import type { MenuAction } from "@react-native-menu/menu";
88import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
9import {
10 Alert,
11 Platform,
12 Pressable,
13 useColorScheme,
14 useWindowDimensions,
15 View,
16} from "react-native";
9import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
1710import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
1811
1912import { SymbolView } from "../../components/AppSymbol";
......@@ -27,6 +20,7 @@ import { useThemeColor } from "../../lib/useThemeColor";
2720import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2821import { useThreadPr } from "../../state/use-thread-pr";
2922import { ThreadSwipeable } from "../home/thread-swipe-actions";
23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
3024import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
3125import {
3226 resolveThreadListV2SnoozeMenuSelection,
......@@ -120,7 +114,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS
120114 readonly onToggle: () => void;
121115 readonly pane?: "screen" | "sidebar";
122116}) {
123 const colorScheme = useColorScheme();
117 const { themeAppearance: colorScheme } = useAppearancePreferences();
124118 return (
125119 <Pressable
126120 accessibilityHint={
......@@ -660,8 +654,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
660654 ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`
661655 : `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 the
664 // v1 sidebar), so every piece of row text needs a white-on-accent variant.
657 // The sidebar pane fills selected rows with the theme's message surface, so
658 // every piece of row text must use that surface's paired foreground.
665659 const cardContent = (
666660 <>
667661 <View className="flex-row items-center gap-1.5">
......@@ -689,7 +683,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
689683 <Text
690684 className={cn(
691685 "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"),
693689 )}
694690 >
695691 {statusLabel?.label ?? timeLabel}
......@@ -766,7 +762,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
766762 {pr ? (
767763 <Text
768764 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)}
770766 style={{ fontFamily: MONO_FONT }}
771767 >
772768 #{pr.label}
apps/mobile/src/features/threads/thread-work-log.tsx+4-5
......@@ -1,12 +1,13 @@
11import * as Haptics from "expo-haptics";
22import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
3import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native";
3import { LayoutAnimation, Pressable, ScrollView, View } from "react-native";
44
55import { AppText as Text } from "../../components/AppText";
66import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
77import { cn } from "../../lib/cn";
88import type { ThreadFeedActivity } from "../../lib/threadActivity";
99import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
10import { useThemeColor } from "../../lib/useThemeColor";
1011import Animated, { FadeIn } from "react-native-reanimated";
1112
1213const WORK_LOG_LAYOUT_ANIMATION = {
......@@ -127,8 +128,7 @@ export function ThreadWorkLog(props: {
127128 readonly onCopyRow: (rowId: string, value: string) => void;
128129 readonly onToggleRow: (rowId: string) => void;
129130}) {
130 const colorScheme = useColorScheme();
131 const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
131 const pressedBackground = useThemeColor("--color-subtle");
132132 const rows = visibleWorkLogActivities(props.activities).map((activity) => ({
133133 ...activity,
134134 detail: compactActivityDetail(activity.detail),
......@@ -281,8 +281,7 @@ export function ThreadWorkGroupToggle(props: {
281281 readonly onlyToolActivities: boolean;
282282 readonly onToggle: () => void;
283283}) {
284 const colorScheme = useColorScheme();
285 const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
284 const pressedBackground = useThemeColor("--color-subtle");
286285 const noun = props.onlyToolActivities
287286 ? props.hiddenCount === 1
288287 ? "tool call"
apps/mobile/src/features/usage/usageProviders.ts+2-2
......@@ -1,5 +1,5 @@
11import type { UsageProviderKind } from "@t3tools/contracts";
2import { useColorScheme } from "react-native";
2import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
33
44/**
55 * Series and table order. The chart stacks providers from the bottom in this
......@@ -17,7 +17,7 @@ export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
1717 * with the theme or its bars vanish against the matching background.
1818 */
1919export function useProviderColors(): Record<UsageProviderKind, string> {
20 const scheme = useColorScheme();
20 const { themeAppearance: scheme } = useAppearancePreferences();
2121 return {
2222 claude: "#d97757",
2323 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", () => {
177177 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
178178 });
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
180199 it("falls back to secure storage when SQLite cannot save preferences", async () => {
181200 mocks.setDatabaseFailures(true, true);
182201 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 @@
1import { DynamicColorIOS, Platform, type ColorValue, type ViewStyle } from "react-native";
2
31/**
4 * One opaque surface for content rendered inside a native form sheet.
5 *
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.
2 * Form sheets inherit the live React Navigation palette supplied by App. Each
3 * presented route paints its content with bg-sheet, including nested pushes.
224 */
235export const FORM_SHEET_PRESENTATION_OPTIONS = {
246 presentation: "formSheet" as const,
25 ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined
26 ? null
27 : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }),
287};
apps/mobile/src/persistence/mobile-preferences.ts+34
......@@ -6,6 +6,7 @@ import * as Ref from "effect/Ref";
66import * as Schema from "effect/Schema";
77import * as Semaphore from "effect/Semaphore";
88import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
9import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
910
1011import * as MobileDatabase from "./mobile-database";
1112import * as MobileSecureStorage from "./mobile-secure-storage";
......@@ -16,6 +17,10 @@ const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";
1617
1718export interface Preferences {
1819 readonly liveActivitiesEnabled?: boolean;
20 readonly themeId?: MobileThemeId;
21 readonly lightThemeId?: MobileThemeId;
22 readonly darkThemeId?: MobileThemeId;
23 readonly themeMode?: MobileThemeMode;
1924 readonly baseFontSize?: number;
2025 readonly terminalFontSize?: number | null;
2126 readonly markdownFontSize?: number;
......@@ -79,6 +84,10 @@ export class MobilePreferencesStore extends Context.Service<
7984function sanitizePreferences(parsed: Preferences): Preferences {
8085 const preferences: {
8186 liveActivitiesEnabled?: boolean;
87 themeId?: MobileThemeId;
88 lightThemeId?: MobileThemeId;
89 darkThemeId?: MobileThemeId;
90 themeMode?: MobileThemeMode;
8291 baseFontSize?: number;
8392 terminalFontSize?: number | null;
8493 markdownFontSize?: number;
......@@ -96,6 +105,31 @@ function sanitizePreferences(parsed: Preferences): Preferences {
96105 if (typeof parsed.liveActivitiesEnabled === "boolean") {
97106 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
98107 }
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 }
99133 if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;
100134 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
101135 preferences.terminalFontSize = parsed.terminalFontSize;
apps/web/src/components/settings/ThemePreviewCircles.tsx+18-17
......@@ -1,5 +1,9 @@
11import { MoonIcon, SunIcon } from "lucide-react";
22import 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";
37import { cn } from "../../lib/utils";
48import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
59import {
......@@ -37,21 +41,17 @@ const STANDARD_THEME_PREVIEW_COLORS: Record<
3741> = {
3842 light: {
3943 sidebar: "#fafafa",
40 canvas: "#fcfcfc",
4144 surface: "#ffffff",
4245 accentSurface: "#f4f4f5",
43 accent: "#f4f4f5",
4446 messageSurface: "#e4e4e7",
45 messageAction: "#4f46e5",
47 ...SHARED_STANDARD_THEME_PREVIEW_COLORS.light,
4648 },
4749 dark: {
4850 sidebar: "#0f0f10",
49 canvas: "#0a0a0a",
5051 surface: "#121212",
5152 accentSurface: "#27272a",
52 accent: "#1c1c1f",
5353 messageSurface: "#27272a",
54 messageAction: "#8b9cff",
54 ...SHARED_STANDARD_THEME_PREVIEW_COLORS.dark,
5555 },
5656};
5757
......@@ -102,23 +102,20 @@ function getThemePreviewStyle(
102102 colors: ThemeCardPreviewColors,
103103 mode: ThemeAppearance,
104104): CSSProperties {
105 const isDark = mode === "dark";
105 const spec = THEME_PREVIEW_RENDER_SPECS[mode];
106106 // The canvas carries the ball's light/dark identity, so it stays dominant:
107107 // a near-true base with a contained accent glow, instead of an accent wash
108108 // that makes both modes read alike.
109 const modeBase = isDark
110 ? `color-mix(in oklab, ${colors.canvas} 80%, #09090b)`
111 : `color-mix(in oklab, ${colors.canvas} 80%, #ffffff)`;
112 const accentPosition = isDark ? "28% 78%" : "72% 22%";
113 const actionPosition = isDark ? "82% 18%" : "18% 82%";
114 const accentFade = isDark ? 62 : 72;
109 const modeBase = `color-mix(in oklab, ${colors.canvas} ${spec.baseWeight * 100}%, ${spec.baseTarget})`;
110 const accentPosition = `${spec.accent.center[0] * 100}% ${spec.accent.center[1] * 100}%`;
111 const actionPosition = `${spec.action.center[0] * 100}% ${spec.action.center[1] * 100}%`;
115112 return {
116113 backgroundColor: modeBase,
117114 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}%)`,
119116 // The action color is a soft tint from the opposite corner, not a second
120117 // 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}%)`,
122119 ].join(", "),
123120 };
124121}
......@@ -145,8 +142,12 @@ export function ThemePreviewCircle({
145142 style={{ boxShadow: themePreviewEdgeShadow(mode) }}
146143 >
147144 <span
148 className="absolute inset-0 scale-110 rounded-full blur-[3px]"
149 style={getThemePreviewStyle(colors, mode)}
145 className="absolute inset-0 rounded-full"
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 }}
150151 />
151152 </span>
152153 );
apps/web/src/themePalette.test.ts+11
......@@ -1,4 +1,5 @@
11import { describe, expect, it, vi } from "vite-plus/test";
2import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
23
34import {
45 applyThemeColorPreview,
......@@ -78,6 +79,16 @@ function contrastRatio(first: string, second: string): number {
7879}
7980
8081describe("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
8192 it("derives a readable palette from extreme simple-editor colors", () => {
8293 const light = createManagedThemeColors("light", "#111827", "#ffff00");
8394 const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00");
apps/web/src/themePalette.ts+18-335
......@@ -1,6 +1,23 @@
11import * as Schema from "effect/Schema";
22import "culori/css";
33import { 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
522export const T3_CHAT_THEME_ID = "t3-chat" as const;
623export const T3_CHAT_THEME_LABEL = "T3 Chat";
......@@ -23,90 +40,11 @@ const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark";
2340export const ThemePreference = Schema.String;
2441export 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];
8743const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES);
88export type ThemeAppearance = "light" | "dark";
89
90export type ThemeColors = Readonly<Record<ThemeColorRole, string>>;
9144export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>;
92export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>;
9345export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>;
9446export type ThemePreferenceMode = ThemeAppearance | "system";
9547export 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}>;
11048export type ThemeFile = Readonly<{
11149 version: typeof THEME_FILE_VERSION;
11250 id: string;
......@@ -366,155 +304,6 @@ function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null {
366304 return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null;
367305}
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
518307/**
519308 * The palette T3 Code wears with no theme installed, captured from the app's
520309 * stock tokens (index.css) so a draft seeded from the default look paints the
......@@ -1431,43 +1220,11 @@ export function createManagedThemeColors(
14311220 };
14321221}
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
14451223/** Theme-file defaults follow the flagship palette for the requested mode. */
14461224export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors {
14471225 return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors;
14481226}
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
14711228/**
14721229 * Update one Advanced-editor color family without normalizing the rest of an
14731230 * imported or hand-tuned palette. The editor exposes a representative role
......@@ -1660,81 +1417,7 @@ export function updateThemeColorFamily(
16601417 }
16611418}
16621419
1663export const GROVE_THEME: ThemeDefinition = {
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];
1420const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = BUILT_IN_THEMES;
17381421
17391422export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null {
17401423 const themeId = themeIdFromPreference(theme);
docs/README.md+1
......@@ -8,6 +8,7 @@
88- [Organizing threads](./user/thread-sidebar.md)
99- [Review usage](./user/usage.md)
1010- [Customize a project icon](./user/project-settings.md)
11- [Mobile appearance](./user/mobile-appearance.md)
1112- [Remote access](./user/remote-access.md)
1213- [Keeping app and server in sync](./user/updating.md)
1314- [Source control integrations](./user/source-control.md)
docs/operations/mobile-app-store-screenshots.md+43-26
......@@ -30,7 +30,7 @@ The command:
30304. Starts an isolated Metro server, builds the selected native apps, and boots each device.
31315. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin.
32326. 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, and
337. Sets the requested system appearance and palette, normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and
3434 validates dimensions, aspect ratio, file size, and screenshot count before succeeding.
35358. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded
3636 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
5151verify process ownership. Concurrent screenshot harnesses in different worktrees can therefore
5252collide or attach to the wrong Metro process.
5353
54Every configured device defaults to dark appearance, so plain `pnpm screenshots:mobile` produces
5530 dark PNGs. Pass `--appearance light`, `--appearance dark`, or `--appearance both` to override the
56configured appearance; `both` produces 60 PNGs.
54Every configured device defaults to dark appearance and the `t3-code` palette, so plain
55`pnpm screenshots:mobile` produces 30 dark PNGs. Pass `--appearance light`, `--appearance dark`, or
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
5864The default matrix is:
5965
60| Output folder | Capture target | Upload dimensions | Store slot |
61| ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- |
62| `apple/iphone-6.9/dark/` | 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 |
64| `apple/ipad-13/dark/` | 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 |
66| `google-play/tablet-7/dark/` | 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 |
68
69Each target captures thread, terminal, review, thread list, and environments. Each appearance
70folder's five screenshots satisfy the configured Apple limit of 1–10, Google
66| Output folder | Capture target | Upload dimensions | Store slot |
67| ------------------------------------- | ------------------------- | ----------------- | ----------------------------------------- |
68| `apple/iphone-6.9/dark/t3-code/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch |
69| `apple/iphone-6.5/dark/t3-code/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch |
70| `apple/ipad-13/dark/t3-code/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape |
71| `google-play/phone/dark/t3-code/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, 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 |
73| `google-play/tablet-10/dark/t3-code/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 |
74
75Each target captures thread, terminal, review, thread list, and environments. Each palette folder's
76five screenshots satisfy the configured Apple limit of 1–10, Google
7177phone 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
7381The generated tree is deliberately aligned with the store upload fields:
7482
7583 artifacts/app-store/screenshots/
7684 ├── apple/
77 │ ├── iphone-6.9/dark/{thread,terminal,review,threads,environments}.png
78 │ ├── iphone-6.5/dark/{thread,terminal,review,threads,environments}.png
79 │ └── ipad-13/dark/{thread,terminal,review,threads,environments}.png
85 │ ├── iphone-6.9/dark/t3-code/{thread,terminal,review,threads,environments}.png
86 │ ├── iphone-6.5/dark/t3-code/{thread,terminal,review,threads,environments}.png
87 │ └── ipad-13/dark/t3-code/{thread,terminal,review,threads,environments}.png
8088 └── google-play/
81 ├── phone/dark/{thread,terminal,review,threads,environments}.png
82 ├── tablet-7/dark/{thread,terminal,review,threads,environments}.png
83 └── tablet-10/dark/{thread,terminal,review,threads,environments}.png
89 ├── phone/dark/t3-code/{thread,terminal,review,threads,environments}.png
90 ├── tablet-7/dark/t3-code/{thread,terminal,review,threads,environments}.png
91 └── tablet-10/dark/t3-code/{thread,terminal,review,threads,environments}.png
8492
8593A 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
8896Edit [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,
90or viewport.
97names, light/dark appearance, default palette, iOS orientation, scenes, output directory, capture
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
92102## Capture in GitHub Actions
93103
94104Run 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 and
96runs iOS and Android concurrently: iPhone and iPad capture on a
105`android`, select `light`, `dark`, or `both`, and pick a palette (or `all`, which raises each job's
106timeout 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
9710812-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a
9810916-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator.
99110
......@@ -120,6 +131,12 @@ Override the configured appearance or capture both variants:
120131 pnpm screenshots:mobile --appearance dark
121132 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
123140Reuse the native build and retain the disposable environment:
124141
125142 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 @@
33 "private": true,
44 "type": "module",
55 "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 },
614 "./projectFavicon": {
715 "types": "./src/projectFavicon.ts",
816 "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 @@
1import {
2 MOBILE_DEFAULT_THEME_ID,
3 MOBILE_THEME_IDS,
4 type MobileThemeId,
5} from "@t3tools/shared/themePalettes";
6
17import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts";
28
39export { SHOWCASE_SCENES };
......@@ -5,6 +11,11 @@ export type { ShowcaseScene };
511
612export 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
819export interface ShowcaseStoreAssetSpec {
920 readonly store: "apple" | "google-play";
1021 /** Device directory relative to ShowcaseConfig.outputDirectory. */
......@@ -25,6 +36,8 @@ export interface ShowcaseIosDevice {
2536 readonly simulatorDeviceType?: string;
2637 /** Appearance used when the CLI does not pass --appearance. */
2738 readonly appearance: ShowcaseAppearance;
39 /** Palette used when the CLI does not pass --theme. */
40 readonly theme: ShowcaseTheme;
2841 /** Orientation applied by the capture harness. Defaults to portrait. */
2942 readonly orientation?: "portrait" | "landscape";
3043 readonly scenes: ReadonlyArray<ShowcaseScene>;
......@@ -38,6 +51,8 @@ export interface ShowcaseAndroidDevice {
3851 readonly avd: string;
3952 /** Appearance used when the CLI does not pass --appearance. */
4053 readonly appearance: ShowcaseAppearance;
54 /** Palette used when the CLI does not pass --theme. */
55 readonly theme: ShowcaseTheme;
4156 /** Native ABI used by the AVD, from its config.ini `abi.type`. */
4257 readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a";
4358 readonly scenes: ReadonlyArray<ShowcaseScene>;
......@@ -92,6 +107,7 @@ const config: ShowcaseConfig = {
92107 simulator: "iPhone 17 Pro Max",
93108 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max",
94109 appearance: "dark",
110 theme: DEFAULT_SHOWCASE_THEME,
95111 scenes: ["thread", "terminal", "review", "threads", "environments"],
96112 storeAsset: {
97113 store: "apple",
......@@ -108,6 +124,7 @@ const config: ShowcaseConfig = {
108124 simulator: "T3 Showcase iPhone 14 Plus",
109125 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus",
110126 appearance: "dark",
127 theme: DEFAULT_SHOWCASE_THEME,
111128 scenes: ["thread", "terminal", "review", "threads", "environments"],
112129 storeAsset: {
113130 store: "apple",
......@@ -124,6 +141,7 @@ const config: ShowcaseConfig = {
124141 simulator: "iPad Pro 13-inch (M5)",
125142 simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB",
126143 appearance: "dark",
144 theme: DEFAULT_SHOWCASE_THEME,
127145 orientation: "landscape",
128146 scenes: ["thread", "terminal", "review", "threads", "environments"],
129147 storeAsset: {
......@@ -143,6 +161,7 @@ const config: ShowcaseConfig = {
143161 // Blacksmith Linux runner can use KVM acceleration.
144162 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
145163 appearance: "dark",
164 theme: DEFAULT_SHOWCASE_THEME,
146165 viewport: {
147166 width: 1080,
148167 height: 1920,
......@@ -165,6 +184,7 @@ const config: ShowcaseConfig = {
165184 avd: "Pixel_10_Pro",
166185 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
167186 appearance: "dark",
187 theme: DEFAULT_SHOWCASE_THEME,
168188 viewport: {
169189 width: 1080,
170190 height: 1920,
......@@ -187,6 +207,7 @@ const config: ShowcaseConfig = {
187207 avd: "Pixel_10_Pro",
188208 abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
189209 appearance: "dark",
210 theme: DEFAULT_SHOWCASE_THEME,
190211 viewport: {
191212 width: 1440,
192213 height: 2560,
scripts/mobile-showcase.test.ts+58-2
......@@ -2,7 +2,9 @@ import { assert, it } from "@effect/vitest";
22import { PNG } from "pngjs";
33
44import showcaseConfig, {
5 DEFAULT_SHOWCASE_THEME,
56 resolveShowcaseAndroidAbi,
7 SHOWCASE_THEMES,
68 type ShowcaseConfig,
79 type ShowcaseStoreAssetSpec,
810} from "./mobile-showcase.config.ts";
......@@ -55,6 +57,7 @@ const config: ShowcaseConfig = {
5557 platform: "ios",
5658 simulator: "iPhone Test",
5759 appearance: "dark",
60 theme: "t3-code",
5861 scenes: ["thread", "review"],
5962 storeAsset: appleSpec,
6063 },
......@@ -63,6 +66,7 @@ const config: ShowcaseConfig = {
6366 platform: "android",
6467 avd: "Pixel_Test",
6568 appearance: "light",
69 theme: "t3-code",
6670 scenes: ["thread", "terminal"],
6771 storeAsset: googleSpec,
6872 },
......@@ -95,6 +99,23 @@ it("rejects unsupported system appearances", () => {
9599 );
96100});
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
98119it("parses validation-only mode", () => {
99120 assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true);
100121});
......@@ -146,8 +167,35 @@ it("expands both appearances into independent upload-ready directories", () => {
146167 directory: showcaseCaptureDirectory("/captures", capture),
147168 })),
148169 [
149 { appearance: "light", directory: "/captures/apple/iphone-test/light" },
150 { appearance: "dark", directory: "/captures/apple/iphone-test/dark" },
170 { appearance: "light", directory: "/captures/apple/iphone-test/light/t3-code" },
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",
151199 ],
152200 );
153201});
......@@ -211,6 +259,14 @@ it("enforces store screenshot count limits", () => {
211259 assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u);
212260});
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
214270it("configures every default device with an exact upload-ready store target", () => {
215271 assert.deepStrictEqual(
216272 showcaseConfig.devices.map((device) => [
scripts/mobile-showcase.ts+48-12
......@@ -20,6 +20,8 @@ import showcaseConfig, {
2020 type ShowcaseStoreAssetSpec,
2121 SHOWCASE_SCENES,
2222 type ShowcaseScene,
23 SHOWCASE_THEMES,
24 type ShowcaseTheme,
2325} from "./mobile-showcase.config.ts";
2426import {
2527 SHOWCASE_ENVIRONMENTS,
......@@ -76,6 +78,7 @@ interface CliOptions {
7678 readonly deviceIds: ReadonlySet<string>;
7779 readonly scenes: ReadonlySet<ShowcaseScene>;
7880 readonly appearances: ReadonlySet<ShowcaseAppearance>;
81 readonly themes: ReadonlySet<ShowcaseTheme>;
7982 readonly skipBuild: boolean;
8083 readonly skipMetro: boolean;
8184 readonly keepRunning: boolean;
......@@ -87,6 +90,7 @@ export interface ShowcaseCapture {
8790 readonly device: ShowcaseDevice;
8891 readonly scenes: ReadonlyArray<ShowcaseScene>;
8992 readonly appearance: ShowcaseAppearance;
93 readonly theme: ShowcaseTheme;
9094}
9195
9296interface IosCaptureCleanup {
......@@ -223,9 +227,16 @@ export function validateStoreAssetCount(
223227
224228export function showcaseCaptureDirectory(
225229 outputDirectory: string,
226 capture: Pick<ShowcaseCapture, "device" | "appearance">,
230 capture: Pick<ShowcaseCapture, "device" | "appearance" | "theme">,
227231): 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 );
229240}
230241
231242async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise<void> {
......@@ -276,6 +287,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
276287 const deviceIds = new Set<string>();
277288 const scenes = new Set<ShowcaseScene>();
278289 const appearances = new Set<ShowcaseAppearance>();
290 const themes = new Set<ShowcaseTheme>();
279291 let skipBuild = false;
280292 let skipMetro = false;
281293 let keepRunning = false;
......@@ -318,6 +330,18 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
318330 appearances.add(value);
319331 }
320332 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;
321345 } else if (argument === "--skip-build") {
322346 skipBuild = true;
323347 } else if (argument === "--skip-metro") {
......@@ -340,6 +364,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
340364 deviceIds,
341365 scenes,
342366 appearances,
367 themes,
343368 skipBuild,
344369 skipMetro,
345370 keepRunning,
......@@ -350,7 +375,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions {
350375
351376export function planShowcaseCaptures(
352377 config: ShowcaseConfig,
353 options: Pick<CliOptions, "platforms" | "deviceIds" | "scenes" | "appearances">,
378 options: Pick<CliOptions, "platforms" | "deviceIds" | "scenes" | "appearances" | "themes">,
354379): ReadonlyArray<ShowcaseCapture> {
355380 const captures = config.devices
356381 .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform))
......@@ -358,14 +383,18 @@ export function planShowcaseCaptures(
358383 .flatMap((device) => {
359384 const appearances =
360385 options.appearances.size === 0 ? [device.appearance] : options.appearances;
361 return [...appearances].map((appearance) => ({
362 device,
363 appearance,
364 scenes:
365 options.scenes.size === 0
366 ? device.scenes
367 : device.scenes.filter((scene) => options.scenes.has(scene)),
368 }));
386 const themes = options.themes.size === 0 ? [device.theme] : options.themes;
387 return [...appearances].flatMap((appearance) =>
388 [...themes].map((theme) => ({
389 device,
390 appearance,
391 theme,
392 scenes:
393 options.scenes.size === 0
394 ? device.scenes
395 : device.scenes.filter((scene) => options.scenes.has(scene)),
396 })),
397 );
369398 })
370399 .filter((capture) => capture.scenes.length > 0);
371400
......@@ -393,6 +422,7 @@ Options:
393422 --scene <name> Capture one scene (repeatable)
394423 --appearance light|dark|both
395424 Override the configured appearance
425 --theme <id>|all Override the configured palette (repeatable)
396426 --skip-build Reuse the existing simulator app / debug APK
397427 --skip-metro Reuse an already running showcase Metro server
398428 --keep-running Leave devices and Metro running after capture
......@@ -400,12 +430,13 @@ Options:
400430 --list Print this help and the configured matrix
401431
402432Scenes: ${SHOWCASE_SCENES.join(", ")}
433Themes: ${SHOWCASE_THEMES.join(", ")}
403434
404435Configured devices:
405436${config.devices
406437 .map((device) => {
407438 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(", ")}]`;
409440 })
410441 .join("\n")}
411442`);
......@@ -946,6 +977,8 @@ async function captureIos(
946977 JSON.stringify(pairingUrls),
947978 "--showcaseScene",
948979 firstScene,
980 "--showcaseTheme",
981 capture.theme,
949982 // The app rotates itself; Simulator menu UI scripting needs macOS
950983 // Accessibility permission that CI runners do not grant to osascript.
951984 "--showcaseOrientation",
......@@ -1204,6 +1237,9 @@ async function captureAndroid(
12041237 "--es",
12051238 "showcaseScene",
12061239 firstScene,
1240 "--es",
1241 "showcaseTheme",
1242 capture.theme,
12071243 ANDROID_PACKAGE,
12081244 ]);
12091245 for (const [sceneIndex, scene] of capture.scenes.entries()) {