diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml
index 3eaaf508e31f..c64bccacdca8 100644
--- a/.github/workflows/mobile-showcase-screenshots.yml
+++ b/.github/workflows/mobile-showcase-screenshots.yml
@@ -21,6 +21,19 @@ on:
- both
- dark
- light
+ theme:
+ description: Palette to capture (all multiplies the run by six)
+ required: true
+ default: t3-code
+ type: choice
+ options:
+ - t3-code
+ - t3-chat
+ - grove
+ - ocean
+ - ember
+ - iris
+ - all
permissions:
contents: read
@@ -33,7 +46,9 @@ jobs:
name: iPhone 6.9, iPhone 6.5, and iPad 13
if: inputs.platform == 'all' || inputs.platform == 'ios'
runs-on: blacksmith-12vcpu-macos-26
- timeout-minutes: 60
+ # Capturing every palette multiplies the device matrix by six, and only the
+ # one native build is shared between them.
+ timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -62,10 +77,10 @@ jobs:
"$vp_pnpm_bin/pnpm" --version
- name: Capture iOS showcase
- run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}"
+ run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}"
- name: Validate App Store Connect assets
- run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only
+ run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only
- name: Upload iOS screenshots
if: always()
@@ -80,7 +95,9 @@ jobs:
name: Android phone, 7-inch tablet, and 10-inch tablet
if: inputs.platform == 'all' || inputs.platform == 'android'
runs-on: blacksmith-16vcpu-ubuntu-2404
- timeout-minutes: 60
+ # Capturing every palette multiplies the device matrix by six, and only the
+ # one native build is shared between them.
+ timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }}
env:
T3_SHOWCASE_ANDROID_ABI: x86_64
steps:
@@ -137,10 +154,10 @@ jobs:
cores: 8
ram-size: 4096M
disable-animations: false
- script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}"
+ script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}"
- name: Validate Google Play assets
- run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only
+ run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only
- name: Upload Android screenshots
if: always()
diff --git a/apps/mobile/global.css b/apps/mobile/global.css
index bfb2448be2a1..a42afc74d92f 100644
--- a/apps/mobile/global.css
+++ b/apps/mobile/global.css
@@ -12,6 +12,7 @@
/* Page backgrounds */
--color-screen: #f2f2f7;
--color-sheet: rgba(242, 242, 247, 0.98);
+ --color-sheet-solid: #f2f2f7;
/* Card / surface */
--color-card: #ffffff;
@@ -39,13 +40,16 @@
/* Primary action */
--color-primary: #262626;
--color-primary-foreground: #ffffff;
- --color-primary-shadow: rgba(0, 0, 0, 0.18);
+ --color-primary-shadow: #000000;
/* Secondary action */
--color-secondary: #ffffff;
--color-secondary-foreground: #262626;
--color-secondary-border: rgba(0, 0, 0, 0.08);
- --color-switch-active: #34c759;
+ --color-switch-active-track: #34c759;
+ --color-switch-active-thumb: #ffffff;
+ --color-switch-inactive-track: rgba(0, 0, 0, 0.08);
+ --color-switch-inactive-thumb: #8e8e93;
/* Danger */
--color-danger: #fef2f2;
@@ -56,7 +60,7 @@
--color-input: #ffffff;
--color-input-border: rgba(0, 0, 0, 0.1);
--color-sidebar-search: rgba(118, 118, 128, 0.12);
- --color-placeholder: #a3a3a3;
+ --color-placeholder: #737373;
/* Icons */
--color-icon: #262626;
@@ -90,6 +94,7 @@
--color-user-bubble: #007aff;
--color-user-bubble-foreground: #ffffff;
--color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
+ --color-user-bubble-skill-foreground: #f0abfc;
/* Drawer / modal backdrop */
--color-backdrop: rgba(0, 0, 0, 0.22);
@@ -106,6 +111,7 @@
/* Page backgrounds */
--color-screen: #0a0a0a;
--color-sheet: rgba(14, 14, 14, 0.98);
+ --color-sheet-solid: #0e0e0e;
/* Card / surface */
--color-card: #171717;
@@ -133,13 +139,16 @@
/* Primary action */
--color-primary: #f5f5f5;
--color-primary-foreground: #0a0a0a;
- --color-primary-shadow: rgba(0, 0, 0, 0.22);
+ --color-primary-shadow: #000000;
/* Secondary action */
--color-secondary: rgba(255, 255, 255, 0.04);
--color-secondary-foreground: #f5f5f5;
--color-secondary-border: rgba(255, 255, 255, 0.06);
- --color-switch-active: #30d158;
+ --color-switch-active-track: #30d158;
+ --color-switch-active-thumb: #ffffff;
+ --color-switch-inactive-track: rgba(255, 255, 255, 0.06);
+ --color-switch-inactive-thumb: #8e8e93;
/* Danger */
--color-danger: rgba(239, 68, 68, 0.14);
@@ -184,6 +193,7 @@
--color-user-bubble: #0a84ff;
--color-user-bubble-foreground: #ffffff;
--color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
+ --color-user-bubble-skill-foreground: #f0abfc;
/* Drawer / modal backdrop */
--color-backdrop: rgba(0, 0, 0, 0.48);
diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt
index f08ca9afb588..6aca0cec234c 100644
--- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt
+++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt
@@ -22,6 +22,12 @@ class T3NativeControlsModule : Module() {
storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene")
}
+ // The palette is fixed for the whole capture, so it only ever arrives as a
+ // launch extra — unlike the scene, which the runner rewrites in place.
+ Function("getShowcaseTheme") {
+ appContext.currentActivity?.intent?.getStringExtra("showcaseTheme")
+ }
+
Function("prepareShowcaseCapture") {
// Android app data is cleared by the host runner before launch.
}
diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
index f3125c3cec94..6aa8fa6bb159 100644
--- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
+++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift
@@ -33,6 +33,19 @@ public final class T3NativeControlsModule: Module {
return arguments[flagIndex + 1]
}
+ // The palette is fixed for the whole capture, so it only ever arrives as a
+ // launch argument — unlike the scene, which the runner rewrites in place.
+ Function("getShowcaseTheme") { () -> String? in
+ let arguments = ProcessInfo.processInfo.arguments
+ guard
+ let flagIndex = arguments.firstIndex(of: "--showcaseTheme"),
+ arguments.indices.contains(flagIndex + 1)
+ else {
+ return nil as String?
+ }
+ return arguments[flagIndex + 1]
+ }
+
Function("getShowcaseOrientation") { () -> String? in
let arguments = ProcessInfo.processInfo.arguments
guard
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 06bd4bc57733..8b219afcc078 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -2,11 +2,11 @@ import { BlurTargetView } from "expo-blur";
import * as Linking from "expo-linking";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
-import { StatusBar, useColorScheme } from "react-native";
+import { StatusBar } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
-import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native";
+import { createStaticNavigation } from "@react-navigation/native";
import { RegistryContext } from "@effect/atom-react";
import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
@@ -22,6 +22,7 @@ import { appAtomRegistry } from "./state/atom-registry";
import { OverlayPortalHost } from "./components/OverlayPortal";
import { appBlurTargetRef } from "./lib/appBlurTarget";
import { useThemeColor } from "./lib/useThemeColor";
+import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
import "../global.css";
@@ -58,45 +59,51 @@ function SplashScreenCoordinator() {
}
export default function App() {
- const colorScheme = useColorScheme();
- const statusBarBg = useThemeColor("--color-status-bar");
-
return (
-
-
-
-
-
- {/* The navigation theme drives the NATIVE header appearance: native-stack
- forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
- this, React Navigation defaults to its light theme and every native
- header (glass buttons, title, materials) is forced light even when
- the system is in dark mode. */}
- {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}
-
-
-
-
-
-
- {/* Anchored-menu overlays render here — in-window, so the
- keyboard stays up while a dropdown is open. */}
-
-
-
-
+
);
}
+
+function AppContent() {
+ const { themeAppearance } = useAppearancePreferences();
+ const statusBarBg = useThemeColor("--color-status-bar");
+ const navigationTheme = useMobileNavigationTheme(themeAppearance);
+
+ return (
+ <>
+
+
+
+
+
+ {/* The navigation theme drives the NATIVE header appearance: native-stack
+ forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
+ this, React Navigation defaults to its light theme and every native
+ header (glass buttons, title, materials) is forced light even when
+ the system is in dark mode. */}
+ {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}
+
+
+
+
+
+
+ {/* Anchored-menu overlays render here — in-window, so the
+ keyboard stays up while a dropdown is open. */}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 20bba1f6062d..7cffbf62b0d7 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -11,7 +11,7 @@ import {
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
import { useEffect, useRef } from "react";
-import { Platform, Pressable, ScrollView, StyleSheet } from "react-native";
+import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
import { useResolveClassNames } from "uniwind";
import { AppText as Text } from "./components/AppText";
@@ -71,11 +71,7 @@ import {
} from "./features/sharing/incoming-share-presentation";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
-import {
- FORM_SHEET_PRESENTATION_OPTIONS,
- NATIVE_SHEET_SURFACE_COLOR,
- NATIVE_SHEET_SURFACE_CONTENT_STYLE,
-} from "./native/sheet-surface";
+import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
@@ -96,11 +92,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
headerLargeTitle: false,
headerShadowVisible: false,
headerShown: true,
- headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED
- ? { backgroundColor: "transparent" }
- : NATIVE_SHEET_SURFACE_COLOR !== undefined
- ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
- : undefined,
+ headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined,
headerTitleStyle: { fontSize: 18, fontWeight: "800" },
headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined,
@@ -115,12 +107,6 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = {
headerLargeTitle: false,
headerShadowVisible: false,
headerShown: true,
- headerStyle:
- NATIVE_SHEET_SURFACE_COLOR !== undefined
- ? // native-stack types this as `string`, but the native side accepts any
- // ColorValue including DynamicColorIOS.
- { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
- : undefined,
headerTitleStyle: { fontSize: 18, fontWeight: "800" },
headerTransparent: false,
unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,
@@ -507,7 +493,6 @@ export const RootStack = createNativeStackNavigator({
linking: `${THREAD_LINKING_PREFIX}/files`,
options: {
...GLASS_HEADER_OPTIONS,
- contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE,
title: "Files",
},
}),
@@ -636,7 +621,11 @@ export const RootStack = createNativeStackNavigator({
// The whole new-task flow (choose project → draft → add project) shares
// draft state via NewTaskFlowProvider. The expo-router era mounted it in
// app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.
- layout: ({ children }) => {children},
+ layout: ({ children }) => (
+
+ {children}
+
+ ),
options: {
gestureEnabled: true,
headerShown: false,
diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx
index c4a0045eefba..7a27e0c3b131 100644
--- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx
+++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx
@@ -3,11 +3,12 @@ import { BlurView } from "expo-blur";
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { StyleProp, ViewStyle } from "react-native";
-import { BackHandler, Pressable, ScrollView, useColorScheme, View } from "react-native";
+import { BackHandler, Pressable, ScrollView, View } from "react-native";
import { useKeyboardState } from "react-native-keyboard-controller";
import Animated, { FadeIn } from "react-native-reanimated";
import { appBlurTargetRef } from "../lib/appBlurTarget";
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { useThemeColor } from "../lib/useThemeColor";
import { cn } from "../lib/cn";
import { type AppSymbolName, SymbolView } from "./AppSymbol";
@@ -79,7 +80,8 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
const anchorRef = useRef(null);
const overlayRef = useRef(null);
- const isDarkMode = useColorScheme() === "dark";
+ const { themeAppearance } = useAppearancePreferences();
+ const isDarkMode = themeAppearance === "dark";
const keyboardVisible = useKeyboardState((state) => state.isVisible);
const keyboardHeight = useKeyboardState((state) => state.height);
const rippleColor = useThemeColor("--color-subtle");
diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx
index 89dc0cc045b2..74308467a052 100644
--- a/apps/mobile/src/components/AppSymbol.tsx
+++ b/apps/mobile/src/components/AppSymbol.tsx
@@ -49,6 +49,7 @@ import {
IconLink,
IconMessage,
IconMinus,
+ IconMoon,
IconNetwork,
IconPalette,
IconPin,
@@ -62,6 +63,7 @@ import {
IconServer,
IconSettings,
IconSparkles,
+ IconSun,
IconLayoutSidebarRight,
IconTerminal2,
IconTextDecrease,
@@ -111,6 +113,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = {
"doc.on.doc": IconCopy,
"doc.text": IconFileText,
ellipsis: IconDots,
+ moon: IconMoon,
"ellipsis.circle": IconDotsCircleHorizontal,
"exclamationmark.triangle": IconAlertTriangle,
eye: IconEye,
@@ -139,6 +142,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = {
"slider.horizontal.3": IconAdjustmentsHorizontal,
"square.and.pencil": IconEdit,
"square.split.2x1": IconLayoutColumns,
+ "sun.max": IconSun,
"stop.fill": IconPlayerStopFilled,
terminal: IconTerminal2,
"text.bubble": IconMessage,
diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx
index de2cca1f6028..de0af576ec95 100644
--- a/apps/mobile/src/components/ComposerToolbar.tsx
+++ b/apps/mobile/src/components/ComposerToolbar.tsx
@@ -4,7 +4,6 @@ import {
Pressable,
ScrollView,
View,
- useColorScheme,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
@@ -13,6 +12,8 @@ import {
} from "react-native";
import { useThemeColor } from "../lib/useThemeColor";
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
+import { themeColorWithAlpha } from "../lib/mobileTheme";
import { cn } from "../lib/cn";
import { AppText as Text } from "./AppText";
import { SymbolView } from "./AppSymbol";
@@ -213,21 +214,22 @@ export function ComposerToolbarButton(props: {
readonly className?: string;
readonly style?: StyleProp;
}) {
- const isDarkMode = useColorScheme() === "dark";
+ const { themeAppearance } = useAppearancePreferences();
+ const isDarkMode = themeAppearance === "dark";
const iconColor = useThemeColor("--color-icon");
const iconSubtle = useThemeColor("--color-icon-subtle");
const primaryFg = useThemeColor("--color-primary-foreground");
const dangerFg = useThemeColor("--color-danger-foreground");
const variant = props.variant ?? "default";
const isCircle = !props.label && props.showChevron === false;
- const defaultBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
- const activeBorderColor = isDarkMode ? "rgba(255,255,255,0.13)" : "rgba(0,0,0,0.1)";
+ const defaultBorderColor = useThemeColor("--color-border-subtle");
+ const activeBorderColor = useThemeColor("--color-border");
const filledBorderColor =
variant === "danger"
- ? "rgba(255,255,255,0.14)"
+ ? themeColorWithAlpha(String(dangerFg), 0.14)
: props.disabled
? defaultBorderColor
- : "rgba(255,255,255,0.18)";
+ : themeColorWithAlpha(String(primaryFg), 0.18);
const iconTintColor =
variant === "primary"
? props.disabled
diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx
index abcfc7f7b803..f05f303d6ccc 100644
--- a/apps/mobile/src/components/ControlPill.tsx
+++ b/apps/mobile/src/components/ControlPill.tsx
@@ -8,8 +8,9 @@ import {
type ReactNode,
useRef,
} from "react";
-import { Platform, Pressable, useColorScheme, View } from "react-native";
+import { Platform, Pressable, View } from "react-native";
import { useThemeColor } from "../lib/useThemeColor";
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { cn } from "../lib/cn";
import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
@@ -117,7 +118,8 @@ export function ControlPillMenu(
readonly className?: string;
},
) {
- const isDarkMode = useColorScheme() === "dark";
+ const { themeAppearance } = useAppearancePreferences();
+ const isDarkMode = themeAppearance === "dark";
if (Platform.OS === "android") {
// Long-press menus keep their child interactive: the child element gets
diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx
index f0b1f863f98b..add1c3b5e7c8 100644
--- a/apps/mobile/src/components/GlassSurface.tsx
+++ b/apps/mobile/src/components/GlassSurface.tsx
@@ -2,7 +2,6 @@ import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
import type { ReactNode } from "react";
import {
Platform,
- useColorScheme,
View,
type ColorValue,
type StyleProp,
@@ -10,6 +9,7 @@ import {
type ViewStyle,
} from "react-native";
import { useThemeColor } from "../lib/useThemeColor";
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
interface GlassSurfaceProps extends Omit {
readonly children: ReactNode;
@@ -29,7 +29,8 @@ export function GlassSurface({
style,
...props
}: GlassSurfaceProps) {
- const isDarkMode = useColorScheme() === "dark";
+ const { themeAppearance } = useAppearancePreferences();
+ const isDarkMode = themeAppearance === "dark";
const borderColor = useThemeColor("--color-border");
const glassSurface = useThemeColor("--color-glass-surface");
const glassTint = useThemeColor("--color-glass-tint");
diff --git a/apps/mobile/src/components/LoadingScreen.tsx b/apps/mobile/src/components/LoadingScreen.tsx
index 2739c5ce4f5e..275381a9c94f 100644
--- a/apps/mobile/src/components/LoadingScreen.tsx
+++ b/apps/mobile/src/components/LoadingScreen.tsx
@@ -1,6 +1,7 @@
-import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native";
+import { ActivityIndicator, StatusBar, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../lib/useThemeColor";
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { AppText as Text } from "./AppText";
import { BrandMark } from "./BrandMark";
@@ -9,7 +10,7 @@ export function LoadingScreen(props: {
readonly message: string;
readonly messagePlacement?: "above-spinner" | "below-spinner";
}) {
- const colorScheme = useColorScheme();
+ const { themeAppearance: colorScheme } = useAppearancePreferences();
const screenBg = useThemeColor("--color-screen");
const insets = useSafeAreaInsets();
const messagePlacement = props.messagePlacement ?? "below-spinner";
diff --git a/apps/mobile/src/components/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx
index fa79c4f60e86..9cb6898fb9ec 100644
--- a/apps/mobile/src/components/PierreEntryIcon.tsx
+++ b/apps/mobile/src/components/PierreEntryIcon.tsx
@@ -3,6 +3,7 @@ import { Image, type ImageStyle, type StyleProp } from "react-native";
import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
+import { useThemeColor } from "../lib/useThemeColor";
export function PierreEntryIcon(props: {
readonly path: string;
@@ -11,8 +12,9 @@ export function PierreEntryIcon(props: {
readonly style?: StyleProp;
}) {
const size = props.size ?? 16;
+ const folderColor = useThemeColor("--color-icon-subtle");
if (props.kind === "directory") {
- return ;
+ return ;
}
return (
diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx
index bdddf2c45951..5eb69627f58d 100644
--- a/apps/mobile/src/components/ProviderIcon.tsx
+++ b/apps/mobile/src/components/ProviderIcon.tsx
@@ -1,5 +1,5 @@
-import { useColorScheme } from "react-native";
import { Path, Svg } from "react-native-svg";
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
type ProviderIconProps = {
readonly provider: string | null | undefined;
@@ -7,7 +7,8 @@ type ProviderIconProps = {
};
export function ProviderIcon(props: ProviderIconProps) {
- const isDarkMode = useColorScheme() === "dark";
+ const { themeAppearance } = useAppearancePreferences();
+ const isDarkMode = themeAppearance === "dark";
const size = props.size ?? 16;
const mono = isDarkMode ? "#e5e5e5" : "#171717";
diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx
new file mode 100644
index 000000000000..270ee084e428
--- /dev/null
+++ b/apps/mobile/src/components/ThemedSwitch.tsx
@@ -0,0 +1,21 @@
+import { Platform, Switch, type SwitchProps } from "react-native";
+
+import { useThemeColor } from "../lib/useThemeColor";
+
+export function ThemedSwitch(props: SwitchProps) {
+ const activeTrack = String(useThemeColor("--color-switch-active-track"));
+ const inactiveTrack = String(useThemeColor("--color-switch-inactive-track"));
+ const activeThumb = String(useThemeColor("--color-switch-active-thumb"));
+ const inactiveThumb = String(useThemeColor("--color-switch-inactive-thumb"));
+
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
index b7cb28376815..6da73eaeb1fa 100644
--- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
+++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
@@ -9,13 +9,13 @@ import { useCallback, useState } from "react";
import {
ActivityIndicator,
Pressable,
- Switch,
type NativeSyntheticEvent,
type TextLayoutEventData,
View,
} from "react-native";
import { AppText as Text } from "../../components/AppText";
+import { ThemedSwitch } from "../../components/ThemedSwitch";
import { cn } from "../../lib/cn";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { useThemeColor } from "../../lib/useThemeColor";
@@ -275,8 +275,6 @@ function CloudEnvironmentRowShell(props: {
readonly statusText?: string;
readonly value: boolean;
}) {
- const activeTrack = String(useThemeColor("--color-switch-active"));
- const track = String(useThemeColor("--color-secondary-border"));
const chevron = useThemeColor("--color-chevron");
const isRetrying =
props.connectionState === "connecting" || props.connectionState === "reconnecting";
@@ -389,11 +387,9 @@ function CloudEnvironmentRowShell(props: {
) : null}
-
diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx
index 9774130eb2eb..942d0b4ffb95 100644
--- a/apps/mobile/src/features/files/SourceFileSurface.tsx
+++ b/apps/mobile/src/features/files/SourceFileSurface.tsx
@@ -2,14 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { ComponentType } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
-import {
- FlatList,
- ScrollView,
- Text as NativeText,
- useColorScheme,
- useWindowDimensions,
- View,
-} from "react-native";
+import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native";
import { AppText as Text } from "../../components/AppText";
import { LoadingStrip } from "../../components/LoadingStrip";
@@ -23,6 +16,7 @@ import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
import { cn } from "../../lib/cn";
import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import {
buildNativeSourceTokens,
NATIVE_SOURCE_CONTENT_WIDTH,
@@ -115,8 +109,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
});
function useSourceFileModel(props: SourceFileSurfaceProps) {
- const colorScheme = useColorScheme();
- const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";
+ const { themeAppearance: theme } = useAppearancePreferences();
const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]);
const { contents: normalizedContents, lines, rowsJson } = document;
const targetIndex =
@@ -159,8 +152,9 @@ function NativeSourceFileSurface(
) {
const { NativeView, onRefresh } = props;
const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
+ const { themeAppearance, themeId } = useAppearancePreferences();
const { width: viewportWidth } = useWindowDimensions();
- const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props);
+ const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props);
const [isPullRefreshing, setIsPullRefreshing] = useState(false);
const handlePullToRefresh = useCallback(async () => {
if (!onRefresh) {
@@ -178,7 +172,10 @@ function NativeSourceFileSurface(
() => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
[targetIndex],
);
- const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]);
+ const themeJson = useMemo(
+ () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId)),
+ [themeAppearance, themeId],
+ );
const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]);
const contentWidth = codeWordBreak
? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24)
@@ -191,7 +188,7 @@ function NativeSourceFileSurface(
collapsable={false}
testID="source-native-code-view"
style={{ flex: 1 }}
- appearanceScheme={theme}
+ appearanceScheme={themeAppearance}
contentResetKey={props.path}
contentWidth={contentWidth}
initialRowIndex={targetIndex ?? -1}
diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
index 7f5105aac177..28356be18524 100644
--- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
+++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
@@ -1,7 +1,7 @@
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { useCallback, useEffect, useRef, useState } from "react";
-import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";
+import { ActivityIndicator, Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import {
@@ -35,6 +35,7 @@ import {
} from "../layout/native-mail-search-toolbar";
import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
import { FileMarkdownPreview } from "./FileMarkdownPreview";
import { FileTreeBrowser } from "./FileTreeBrowser";
@@ -242,10 +243,10 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =
useAdaptiveWorkspaceLayout();
const [searchQuery, setSearchQuery] = useState("");
- const colorScheme = useColorScheme();
const isAndroid = Platform.OS === "android";
- const highlightTheme = colorScheme === "dark" ? "dark" : "light";
+ const { themeAppearance: highlightTheme } = useAppearancePreferences();
const iconColor = String(useThemeColor("--color-icon-muted"));
+ const sheetSurfaceColor = String(useThemeColor("--color-sheet-solid"));
const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
props.route.params,
);
@@ -362,10 +363,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
return (
<>
- {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx.
- Only genuinely dynamic options are set here. */}
+ {/* Static header config (glass preset and title) lives in Stack.tsx. The
+ live sheet color stays dynamic here so the FlatList can remain the
+ direct scene child for native scroll-edge sampling. */}
0 ? projectName : undefined,
diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx
index c06f7cc96951..e13f3f61b51b 100644
--- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx
+++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx
@@ -1,7 +1,7 @@
import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";
import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useMemo, useState, type ComponentProps } from "react";
-import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native";
+import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native";
import {
Screen,
ScreenStack,
@@ -15,6 +15,7 @@ import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { useThemeColor } from "../../lib/useThemeColor";
import { projectEnvironment } from "../../state/projects";
import { useEnvironmentQuery } from "../../state/query";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { FileTreeBrowser } from "./FileTreeBrowser";
import { preloadWorkspaceFileContents } from "./preload-workspace-file";
@@ -27,8 +28,7 @@ export function ThreadFileNavigatorPane(props: {
readonly onSelectFile: (path: string) => void;
}) {
const [searchQuery, setSearchQuery] = useState("");
- const colorScheme = useColorScheme();
- const highlightTheme = colorScheme === "dark" ? "dark" : "light";
+ const { themeAppearance: highlightTheme } = useAppearancePreferences();
const iconColor = String(useThemeColor("--color-icon-muted"));
const foregroundColor = String(useThemeColor("--color-foreground"));
const sheetColor = String(useThemeColor("--color-sheet"));
diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx
index 3be3cdf602db..d476452efa58 100644
--- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx
+++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx
@@ -1,14 +1,8 @@
import { useCallback, useMemo, useRef, useState } from "react";
-import {
- Platform,
- PlatformColor,
- Pressable,
- StyleSheet,
- View,
- type AccessibilityActionEvent,
-} from "react-native";
+import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { runOnJS } from "react-native-reanimated";
+import { useThemeColor } from "../../lib/useThemeColor";
const ACCESSIBILITY_RESIZE_STEP = 24;
@@ -28,6 +22,8 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
latestProps.current = props;
const [hovered, setHovered] = useState(false);
const [dragging, setDragging] = useState(false);
+ const dividerColor = useThemeColor("--color-border");
+ const activeDividerColor = useThemeColor("--color-primary");
const handleResizeStart = useCallback(() => {
setDragging(true);
latestProps.current.onResizeStart?.();
@@ -84,7 +80,13 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
>
-
+
);
@@ -93,14 +95,11 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
const styles = StyleSheet.create({
line: {
alignSelf: "center",
- backgroundColor:
- Platform.OS === "ios" ? PlatformColor("separator") : "rgba(120, 120, 128, 0.28)",
height: "100%",
opacity: 0.7,
width: StyleSheet.hairlineWidth,
},
activeLine: {
- backgroundColor: Platform.OS === "ios" ? PlatformColor("systemBlueColor") : "#0a84ff",
opacity: 1,
width: 2,
},
diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx
index c6d678ddca95..40f8fcf153bb 100644
--- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx
+++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx
@@ -2,14 +2,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"
import { TextInputWrapper } from "expo-paste-input";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useCallback, useEffect, useMemo, useState } from "react";
-import {
- Platform,
- Pressable,
- ScrollView,
- View,
- useColorScheme,
- useWindowDimensions,
-} from "react-native";
+import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ImageViewing from "react-native-image-viewing";
@@ -33,10 +26,10 @@ import {
useReviewCommentTarget,
} from "./reviewCommentSelection";
import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRendering";
import {
highlightReviewSelectedLines,
- type ReviewDiffTheme,
type ReviewHighlightedToken,
} from "./shikiReviewHighlighter";
@@ -52,7 +45,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { width } = useWindowDimensions();
- const colorScheme = useColorScheme();
+ const { themeAppearance: selectedTheme } = useAppearancePreferences();
const iconTint = String(useThemeColor("--color-icon"));
const target = useReviewCommentTarget();
const { codeSurface } = useAppearanceCodeSurface();
@@ -72,7 +65,6 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
const lastLine = selectedLines[selectedLines.length - 1] ?? null;
const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;
const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;
- const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
const canSubmit =
commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId;
const selectionLabel =
@@ -162,7 +154,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
}, [attachments, commentText, dismissComposer, environmentId, target, threadId]);
return (
-
+
void) | null;
readonly onClear: () => void;
}) {
+ const foreground = useThemeColor("--color-primary-foreground");
if (!props.title) {
return null;
}
@@ -106,10 +107,10 @@ function ReviewSelectionActionBar(props: {
- {props.title}
+ {props.title}
>
);
@@ -128,22 +129,22 @@ function ReviewSelectionActionBar(props: {
>
{props.onOpenComment ? (
{content}
) : (
-
+
{content}
)}
-
+
);
@@ -346,7 +347,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
- const colorScheme = useColorScheme();
+ const { themeAppearance: selectedTheme } = useAppearancePreferences();
const headerIcon = String(useThemeColor("--color-icon"));
const { environmentId, threadId } = props.route.params;
const environment = useEnvironmentPresentation(environmentId);
@@ -371,7 +372,6 @@ export function ReviewSheet(props: ReviewSheetProps) {
// selected thread (it always does when reached from the thread's toolbar).
const gitMenuAvailable =
selectedThread !== null && String(selectedThread.id) === String(threadId);
- const selectedTheme = colorScheme === "dark" ? "dark" : "light";
// With a solid (non-overlay) header the content lays out below the header
// natively, so no manual top inset is needed. (Android renders its own
// in-flow AndroidScreenHeader, so it needs no inset either.)
@@ -436,7 +436,6 @@ export function ReviewSheet(props: ReviewSheetProps) {
sectionId: selectedSection?.id ?? null,
diff: selectedSection?.diff,
data: nativeReviewDiffData,
- scheme: selectedTheme,
collapsedFileIds,
viewedFileIds,
selectedRowIds: commentSelection.selectedRowIds,
@@ -444,7 +443,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
});
const showcaseReviewKey =
SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection
- ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}`
+ ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}:${nativeBridge.themeId}`
: null;
const handleNativeDebug = useCallback(
(event: NativeSyntheticEvent>) => {
@@ -457,9 +456,9 @@ export function ReviewSheet(props: ReviewSheetProps) {
return;
}
showcasedReviewDrawRef.current = showcaseReviewKey;
- markNativeShowcaseReady("review");
+ reportShowcaseSceneRendered({ scene: "review", themeId: nativeBridge.themeId });
},
- [nativeBridge.onDebug, showcaseReviewKey],
+ [nativeBridge.onDebug, nativeBridge.themeId, showcaseReviewKey],
);
const handleSelectFile = useCallback(
diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts
index 1722b06d6f8f..dbd1d7aeb0b9 100644
--- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts
+++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vite-plus/test";
+import { MOBILE_THEME_IDS } from "../../lib/mobileTheme";
import {
+ createNativeReviewDiffTheme,
getCachedNativeReviewDiffData,
type BuildNativeReviewDiffDataInput,
} from "./nativeReviewDiffAdapter";
@@ -54,3 +56,26 @@ describe("getCachedNativeReviewDiffData", () => {
expect(changed).not.toBe(first);
});
});
+
+describe("createNativeReviewDiffTheme", () => {
+ it("serializes every native color as cross-platform opaque hex", () => {
+ for (const themeId of MOBILE_THEME_IDS) {
+ for (const appearance of ["light", "dark"] as const) {
+ const theme = createNativeReviewDiffTheme(appearance, themeId);
+ for (const color of Object.values(theme)) {
+ expect(color, `${themeId}/${appearance}`).toMatch(/^#[\da-f]{6}$/i);
+ }
+ }
+ }
+ });
+
+ it("uses the selected app palette for native code surfaces", () => {
+ const standard = createNativeReviewDiffTheme("dark", "t3-code");
+ const iris = createNativeReviewDiffTheme("dark", "iris");
+
+ expect(iris.background).not.toBe(standard.background);
+ expect(iris.hunkText).not.toBe(standard.hunkText);
+ expect(iris.addBar).toBe(standard.addBar);
+ expect(iris.deleteBar).toBe(standard.deleteBar);
+ });
+});
diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts
index 6d82940bb029..66beae22e9fc 100644
--- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts
+++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts
@@ -8,7 +8,12 @@ import { pipe } from "effect/Function";
import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
import { MOBILE_CODE_SURFACE } from "../../lib/typography";
-import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
+import {
+ DEFAULT_MOBILE_THEME_ID,
+ getMobileThemeVariables,
+ type MobileThemeId,
+} from "../../lib/mobileTheme";
+import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
import { computeWordAltDiffRanges } from "./reviewWordDiffs";
import {
getReviewFilePreviewState,
@@ -20,6 +25,9 @@ import type { ReviewInlineComment } from "./reviewCommentSelection";
const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;
const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;
+const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;
+const NATIVE_RGBA_COLOR =
+ /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/;
export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
@@ -28,6 +36,23 @@ export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle(
resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
);
+function opaqueNativeHexColor(color: string, background: string): string {
+ const hex = NATIVE_HEX_COLOR.exec(color);
+ if (hex) return color;
+
+ const rgba = NATIVE_RGBA_COLOR.exec(color);
+ const backgroundHex = NATIVE_HEX_COLOR.exec(background);
+ if (!rgba || !backgroundHex) return background;
+
+ const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4])));
+ const channels = [1, 2, 3].map((index) => {
+ const foreground = Number(rgba[index]);
+ const behind = Number.parseInt(backgroundHex[index], 16);
+ return Math.round(foreground * alpha + behind * (1 - alpha));
+ });
+ return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
+}
+
export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) {
return {
rowHeight: codeSurface.rowHeight,
@@ -112,21 +137,28 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray opaqueNativeHexColor(color, background);
if (scheme === "dark") {
return {
// Match the app surface (--color-sheet) so code views blend with the rest of
// the app instead of using a distinct code-editor background.
- background: "#0e0e0e",
- text: terminalTheme.foreground,
- mutedText: terminalTheme.mutedForeground,
- headerBackground: "#0e0e0e",
- border: terminalTheme.border,
- hunkBackground: "#071f28",
- hunkText: terminalBlue ?? "#009fff",
+ background,
+ text: nativeColor(appTheme["--color-md-code-text"]),
+ mutedText: nativeColor(appTheme["--color-foreground-muted"]),
+ headerBackground: background,
+ border: nativeColor(appTheme["--color-border"]),
+ hunkBackground: nativeColor(appTheme["--color-subtle-strong"]),
+ hunkText: nativeColor(appTheme["--color-primary"]),
addBackground: "#0d2f28",
deleteBackground: "#391415",
addBar: "#00cab1",
@@ -139,13 +171,13 @@ export function createNativeReviewDiffTheme(
return {
// Match the app surface (--color-sheet) so code views blend with the rest of the
// app instead of using a distinct code-editor background.
- background: "#f2f2f7",
- text: "#070707",
- mutedText: terminalTheme.mutedForeground,
- headerBackground: "#f2f2f7",
- border: terminalTheme.border,
- hunkBackground: "#e0f2ff",
- hunkText: terminalBlue ?? "#009fff",
+ background,
+ text: nativeColor(appTheme["--color-md-code-text"]),
+ mutedText: nativeColor(appTheme["--color-foreground-muted"]),
+ headerBackground: background,
+ border: nativeColor(appTheme["--color-border"]),
+ hunkBackground: nativeColor(appTheme["--color-subtle-strong"]),
+ hunkText: nativeColor(appTheme["--color-primary"]),
addBackground: "#e5f8f5",
deleteBackground: "#ffe6e7",
addBar: "#00cab1",
diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts
index d28e45844f68..f5effb9485d1 100644
--- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts
+++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts
@@ -1,9 +1,9 @@
import { useCallback, useMemo, useState } from "react";
import type { NativeSyntheticEvent } from "react-native";
-import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter";
import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter";
import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting";
import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys";
@@ -14,7 +14,6 @@ export function useNativeReviewDiffBridge(input: {
readonly sectionId: string | null;
readonly diff: string | null | undefined;
readonly data: NativeReviewDiffData;
- readonly scheme: NativeReviewDiffHighlightScheme;
readonly collapsedFileIds: ReadonlyArray;
readonly viewedFileIds: ReadonlyArray;
readonly selectedRowIds: ReadonlyArray;
@@ -25,18 +24,18 @@ export function useNativeReviewDiffBridge(input: {
collapsedFileIds,
data,
diff,
- scheme,
sectionId,
selectedRowIds,
threadKey,
viewedFileIds,
} = input;
const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
+ const { themeAppearance: scheme, themeId } = useAppearancePreferences();
const [collapsedCommentIds, setCollapsedCommentIds] = useState>(
() => new Set(),
);
- const theme = useMemo(() => createNativeReviewDiffTheme(scheme), [scheme]);
+ const theme = useMemo(() => createNativeReviewDiffTheme(scheme, themeId), [scheme, themeId]);
const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]);
const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]);
const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]);
@@ -106,6 +105,7 @@ export function useNativeReviewDiffBridge(input: {
);
return {
+ themeId,
theme,
rowsJson,
collapsedFileIdsJson,
diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx
index 5b62942bb697..a97193d6b6a5 100644
--- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx
@@ -7,6 +7,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";
+import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection";
export function SettingsAppearanceRouteScreen() {
const navigation = useNavigation();
@@ -29,6 +30,7 @@ export function SettingsAppearanceRouteScreen() {
paddingBottom: Math.max(insets.bottom, 18) + 18,
}}
>
+
diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
index aa30242ea72a..6b6d589fa4f3 100644
--- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
@@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
import { useNavigation } from "@react-navigation/native";
import { SymbolView } from "../../components/AppSymbol";
import type { EnvironmentId } from "@t3tools/contracts";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useState } from "react";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -20,7 +20,6 @@ import {
SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS,
} from "../showcase/showcaseEnvironmentRows";
-import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene";
const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
@@ -48,12 +47,6 @@ export function SettingsEnvironmentsRouteScreen() {
const accentColor = useThemeColor("--color-icon-muted");
const headerIconColor = useThemeColor("--color-icon");
- useEffect(() => {
- if (!SHOWCASE_ENABLED) return;
- const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500);
- return () => clearTimeout(timer);
- }, []);
-
const handleToggle = useCallback((environmentId: EnvironmentId) => {
setExpandedId((prev) => (prev === environmentId ? null : environmentId));
}, []);
diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx
index 7eaafc0e9e65..96a01c051126 100644
--- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx
+++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx
@@ -1,4 +1,5 @@
-import { createContext, use, useCallback, useEffect, useMemo, type ReactNode } from "react";
+import { createContext, use, useCallback, useLayoutEffect, useMemo, type ReactNode } from "react";
+import { useColorScheme } from "react-native";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
@@ -9,16 +10,37 @@ import {
resolveAppearance,
resolveAppearancePreferences,
resolveTextScaleVariables,
- type AppearancePreferences,
type ResolvedAppearance,
} from "../../../lib/appearancePreferences";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
+import type { Preferences } from "../../../persistence/mobile-preferences";
+import {
+ createMobileThemePairPatch,
+ createMobileThemeSelectionPatch,
+ getMobileThemeVariables,
+ normalizeMobileThemeMode,
+ resolveMobileThemeIds,
+ type MobileThemeAppearance,
+ type MobileThemeId,
+ type MobileThemeIds,
+ type MobileThemeMode,
+} from "../../../lib/mobileTheme";
import { cacheTerminalFontSize } from "../../terminal/terminalUiState";
interface AppearancePreferencesContextValue {
/** Effective values with base-size derivation applied. Use this for rendering. */
readonly appearance: ResolvedAppearance;
+ readonly themeId: MobileThemeId;
+ readonly themeIds: MobileThemeIds;
+ readonly themeMode: MobileThemeMode;
+ readonly themeAppearance: MobileThemeAppearance;
readonly isReady: boolean;
+ readonly setThemeIdForAppearance: (
+ appearance: MobileThemeAppearance,
+ value: MobileThemeId,
+ ) => void;
+ readonly setThemeIdForBothAppearances: (value: MobileThemeId) => void;
+ readonly setThemeMode: (value: MobileThemeMode) => void;
readonly setBaseFontSize: (value: number) => void;
/** Pass null to clear the override and follow the base font size. */
readonly setTerminalFontSize: (value: number | null) => void;
@@ -30,46 +52,85 @@ interface AppearancePreferencesContextValue {
const AppearancePreferencesContext = createContext(null);
/**
- * Injects the scaled `--text-*` variables into Uniwind so every
- * className-based text size (`text-sm`, `text-base`, ...) re-resolves live.
- * Updates the current theme last so the active stylesheet settles correctly.
+ * Injects palette and text-scale variables into both adaptive stylesheets.
+ * Updating the active sheet last lets the visible app settle in one pass.
*/
-function applyTextScaleVariables(baseFontSize: number) {
- const variables = resolveTextScaleVariables(baseFontSize);
+function applyAppearanceVariables(baseFontSize: number, themeIds: MobileThemeIds) {
+ const textVariables = resolveTextScaleVariables(baseFontSize);
const currentTheme = Uniwind.currentTheme;
+ const activeAppearance =
+ currentTheme === "light" || currentTheme === "dark" ? currentTheme : null;
for (const theme of ["light", "dark"] as const) {
- if (theme !== currentTheme) {
+ const variables = { ...getMobileThemeVariables(themeIds[theme], theme), ...textVariables };
+ if (theme !== activeAppearance) {
Uniwind.updateCSSVariables(theme, variables);
}
}
- Uniwind.updateCSSVariables(currentTheme, variables);
+ if (activeAppearance !== null) {
+ Uniwind.updateCSSVariables(activeAppearance, {
+ ...getMobileThemeVariables(themeIds[activeAppearance], activeAppearance),
+ ...textVariables,
+ });
+ }
}
export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
+ const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light";
+ const storedPreferences = AsyncResult.isSuccess(preferencesResult)
+ ? preferencesResult.value
+ : null;
const preferences = useMemo(
- () =>
- resolveAppearancePreferences(
- AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null,
- ),
- [preferencesResult],
+ () => resolveAppearancePreferences(storedPreferences),
+ [storedPreferences],
+ );
+ const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode);
+ const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode;
+ const themeIds = useMemo(
+ () => resolveMobileThemeIds(storedPreferences ?? {}),
+ [storedPreferences],
);
+ const themeId = themeIds[themeAppearance];
const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
- useEffect(() => {
- applyTextScaleVariables(preferences.baseFontSize);
+ useLayoutEffect(() => {
+ applyAppearanceVariables(preferences.baseFontSize, themeIds);
+ Uniwind.setTheme(themeMode);
cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize);
- }, [preferences]);
+ }, [preferences, themeIds, themeMode]);
const updatePreferences = useCallback(
- (patch: Partial) => {
+ (patch: Partial) => {
savePreferences(patch);
},
[savePreferences],
);
+ const setThemeIdForAppearance = useCallback(
+ (appearance: MobileThemeAppearance, value: MobileThemeId) => {
+ updatePreferences(
+ createMobileThemeSelectionPatch(themeIds, themeAppearance, appearance, value),
+ );
+ },
+ [themeAppearance, themeIds, updatePreferences],
+ );
+
+ const setThemeIdForBothAppearances = useCallback(
+ (value: MobileThemeId) => {
+ updatePreferences(createMobileThemePairPatch(value));
+ },
+ [updatePreferences],
+ );
+
+ const setThemeMode = useCallback(
+ (value: MobileThemeMode) => {
+ updatePreferences({ themeMode: value });
+ },
+ [updatePreferences],
+ );
+
const setBaseFontSize = useCallback(
(value: number) => {
updatePreferences({ baseFontSize: value });
@@ -101,13 +162,34 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
const value = useMemo(
(): AppearancePreferencesContextValue => ({
appearance: resolveAppearance(preferences),
+ themeId,
+ themeIds,
+ themeMode,
+ themeAppearance,
isReady,
+ setThemeIdForAppearance,
+ setThemeIdForBothAppearances,
+ setThemeMode,
setBaseFontSize,
setTerminalFontSize,
setCodeFontSize,
setCodeWordBreak,
}),
- [preferences, isReady, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak],
+ [
+ preferences,
+ themeId,
+ themeIds,
+ themeMode,
+ themeAppearance,
+ isReady,
+ setThemeIdForAppearance,
+ setThemeIdForBothAppearances,
+ setThemeMode,
+ setBaseFontSize,
+ setTerminalFontSize,
+ setCodeFontSize,
+ setCodeWordBreak,
+ ],
);
return (
diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx
index b111035c01fe..f9275eb37385 100644
--- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx
+++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx
@@ -1,11 +1,4 @@
-import {
- Platform,
- ScrollView,
- type StyleProp,
- type TextStyle,
- View,
- useColorScheme,
-} from "react-native";
+import { Platform, ScrollView, type StyleProp, type TextStyle, View } from "react-native";
import { AppText as Text } from "../../../../components/AppText";
import {
@@ -13,7 +6,8 @@ import {
resolveMobileCodeSurface,
} from "../../../../lib/appearancePreferences";
import { useThemeColor } from "../../../../lib/useThemeColor";
-import { getPierreTerminalTheme } from "../../../terminal/terminalTheme";
+import { getMobileTerminalTheme } from "../../../terminal/terminalTheme";
+import { useAppearancePreferences } from "../AppearancePreferencesProvider";
const CODE_FONT_FAMILY = Platform.select({
ios: "ui-monospace",
@@ -53,8 +47,8 @@ export function TextAppearancePreview(props: { readonly fontSize: number }) {
* on the shared card background so it reads like the other previews.
*/
export function TerminalAppearancePreview(props: { readonly fontSize: number }) {
- const scheme = useColorScheme() === "light" ? "light" : "dark";
- const theme = getPierreTerminalTheme(scheme);
+ const { themeAppearance: scheme, themeId } = useAppearancePreferences();
+ const theme = getMobileTerminalTheme(themeId, scheme);
const lineHeight = Math.round(props.fontSize * 1.6);
const lineStyle = {
fontFamily: "Menlo",
diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx
new file mode 100644
index 000000000000..ab2a99313985
--- /dev/null
+++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx
@@ -0,0 +1,365 @@
+import { memo, useId } from "react";
+import { Pressable, View } from "react-native";
+import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
+
+import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview";
+
+import { SymbolView } from "../../../../components/AppSymbol";
+import { AppText as Text } from "../../../../components/AppText";
+import {
+ getMobileThemeVariables,
+ getMobileThemePreviewColors,
+ MOBILE_THEME_OPTIONS,
+ type MobileThemeAppearance,
+ type MobileThemeId,
+ type MobileThemeIds,
+ type MobileThemeMode,
+ type MobileThemeVariables,
+} from "../../../../lib/mobileTheme";
+import { useThemeColor } from "../../../../lib/useThemeColor";
+import { useAppearancePreferences } from "../AppearancePreferencesProvider";
+
+const APPEARANCE_MODES: ReadonlyArray<{
+ readonly id: MobileThemeMode;
+ readonly label: string;
+}> = [
+ { id: "system", label: "System" },
+ { id: "light", label: "Light" },
+ { id: "dark", label: "Dark" },
+];
+
+const PreviewOrb = memo(function PreviewOrb(props: {
+ readonly appearance: MobileThemeAppearance;
+ readonly compact?: boolean;
+ readonly themeId: MobileThemeId;
+}) {
+ const idPrefix = useId().replaceAll(":", "");
+ const accentGradientId = `${idPrefix}-accent-glow`;
+ const actionGradientId = `${idPrefix}-action-glow`;
+ const colors = getMobileThemePreviewColors(props.themeId, props.appearance);
+ const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance];
+ const accentRadius = Math.hypot(
+ Math.max(spec.accent.center[0], 1 - spec.accent.center[0]),
+ Math.max(spec.accent.center[1], 1 - spec.accent.center[1]),
+ );
+ const actionRadius = Math.hypot(
+ Math.max(spec.action.center[0], 1 - spec.action.center[0]),
+ Math.max(spec.action.center[1], 1 - spec.action.center[1]),
+ );
+ const position = (value: number) => `${value * 100}%`;
+ const radius = (value: number) => `${value * 100}%`;
+
+ return (
+
+
+
+ );
+});
+
+function ThemeCard(props: {
+ readonly disabled: boolean;
+ readonly darkSelected: boolean;
+ readonly label: string;
+ readonly lightSelected: boolean;
+ readonly onSelectBoth: () => void;
+ readonly onSelect: (appearance: MobileThemeAppearance) => void;
+ readonly themeId: MobileThemeId;
+}) {
+ const badgeBackground = useThemeColor("--color-card");
+ const badgeIcon = useThemeColor("--color-icon");
+
+ const choice = (appearance: MobileThemeAppearance, selected: boolean) => (
+ props.onSelect(appearance)}
+ >
+
+ {selected ? (
+
+
+
+ ) : null}
+
+ );
+
+ return (
+
+
+
+ {choice("light", props.lightSelected)}
+ {choice("dark", props.darkSelected)}
+
+
+
+ {props.label}
+
+
+
+ );
+}
+
+function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly compact?: boolean }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) {
+ const light = getMobileThemeVariables(props.themeIds.light, "light");
+ const dark = getMobileThemeVariables(props.themeIds.dark, "dark");
+ const currentBorder = useThemeColor("--color-border");
+ const currentFrame = useThemeColor("--color-drawer");
+ const currentIndicator = useThemeColor("--color-foreground-muted");
+ const frameColor =
+ props.mode === "light"
+ ? light["--color-border"]
+ : props.mode === "dark"
+ ? dark["--color-border"]
+ : currentBorder;
+ const frameBackground =
+ props.mode === "light"
+ ? light["--color-drawer"]
+ : props.mode === "dark"
+ ? dark["--color-drawer"]
+ : currentFrame;
+ const indicatorColor =
+ props.mode === "light"
+ ? light["--color-foreground-muted"]
+ : props.mode === "dark"
+ ? dark["--color-foreground-muted"]
+ : currentIndicator;
+
+ return (
+
+
+ {props.mode === "system" ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function ModeCard(props: {
+ readonly disabled: boolean;
+ readonly label: string;
+ readonly mode: MobileThemeMode;
+ readonly onPress: () => void;
+ readonly selected: boolean;
+ readonly themeIds: MobileThemeIds;
+}) {
+ return (
+
+
+
+ {props.label}
+
+
+ );
+}
+
+function SectionLabel({ children }: { readonly children: string }) {
+ return {children};
+}
+
+export function ThemeAppearanceSection() {
+ const {
+ isReady,
+ setThemeIdForAppearance,
+ setThemeIdForBothAppearances,
+ setThemeMode,
+ themeIds,
+ themeMode,
+ } = useAppearancePreferences();
+
+ return (
+
+
+ Color scheme
+
+ {APPEARANCE_MODES.map((mode) => (
+ setThemeMode(mode.id)}
+ selected={mode.id === themeMode}
+ themeIds={themeIds}
+ />
+ ))}
+
+
+
+
+ Themes
+
+ {MOBILE_THEME_OPTIONS.map((theme) => (
+ setThemeIdForAppearance(appearance, theme.id)}
+ onSelectBoth={() => setThemeIdForBothAppearances(theme.id)}
+ themeId={theme.id}
+ />
+ ))}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx
index c6c835f11940..2a63385a04b1 100644
--- a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx
+++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx
@@ -1,8 +1,9 @@
import type { ComponentProps } from "react";
-import { Switch, View } from "react-native";
+import { View } from "react-native";
import { SymbolView } from "../../../components/AppSymbol";
import { AppText as Text } from "../../../components/AppText";
+import { ThemedSwitch } from "../../../components/ThemedSwitch";
import { useThemeColor } from "../../../lib/useThemeColor";
type SymbolName = ComponentProps["name"];
@@ -16,8 +17,6 @@ export function SettingsSwitchRow(props: {
readonly onValueChange: (value: boolean) => void;
}) {
const icon = useThemeColor("--color-icon");
- const activeTrack = String(useThemeColor("--color-switch-active"));
- const track = String(useThemeColor("--color-secondary-border"));
return (
{props.subtitle}
) : null}
-
diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx
index 424822c35eb0..557c3b190359 100644
--- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx
+++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { Keyboard, View } from "react-native";
import {
CommonActions,
@@ -10,6 +10,8 @@ import {
import { AsyncResult } from "effect/unstable/reactivity";
import { useConnectionController } from "../connection/useConnectionController";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
+import type { MobileThemeId } from "../../lib/mobileTheme";
import { useProjects, useThreadShells } from "../../state/entities";
import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";
@@ -19,6 +21,7 @@ import {
getNativeShowcaseOrientation,
getNativeShowcasePairingUrls,
getNativeShowcaseScene,
+ getNativeShowcaseTheme,
markNativeShowcaseReady,
type ShowcaseScene,
} from "./nativeShowcaseScene";
@@ -27,6 +30,12 @@ import {
SHOWCASE_PENDING_TASK_DEFINITIONS,
} from "./showcasePendingTasks";
import { retryShowcaseOperation } from "./showcaseRetry";
+import {
+ clearShowcaseRenderSignal,
+ getShowcaseRenderSignal,
+ isShowcaseNativeContentReady,
+ subscribeToShowcaseRenderSignal,
+} from "./showcaseRenderSignal";
const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
const SHOWCASE_THREAD_ID = "remote-command-center";
@@ -48,6 +57,12 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null {
export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) {
const navigation = useNavigation();
const { connectPairingUrl } = useConnectionController();
+ const {
+ isReady: appearancePreferencesReady,
+ themeId,
+ themeIds,
+ setThemeIdForBothAppearances,
+ } = useAppearancePreferences();
const workspace = useWorkspaceState();
const projects = useProjects();
const threads = useThreadShells();
@@ -56,18 +71,32 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
const [pairingUrls, setPairingUrls] = useState>([]);
const [pendingTasksReady, setPendingTasksReady] = useState(false);
const [requestedScene, setRequestedScene] = useState(null);
+ const [requestedTheme, setRequestedTheme] = useState(null);
+ const [themeRequestSettled, setThemeRequestSettled] = useState(false);
const [readyScene, setReadyScene] = useState(null);
const [orientationSettled, setOrientationSettled] = useState(false);
+ const requestedSceneRef = useRef(null);
+ const renderSignal = useSyncExternalStore(
+ subscribeToShowcaseRenderSignal,
+ getShowcaseRenderSignal,
+ getShowcaseRenderSignal,
+ );
useEffect(() => {
if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return;
- const readPairingUrls = () => {
+ const readLaunchRequest = () => {
const values = getNativeShowcasePairingUrls();
- if (values.length > 0) setPairingUrls(values);
+ if (values.length === 0) return;
+ // The palette rides the same launch request as the pairing URLs, so
+ // reading it here settles it without a timeout that could expire while
+ // the request is still on its way.
+ setRequestedTheme(getNativeShowcaseTheme());
+ setThemeRequestSettled(true);
+ setPairingUrls(values);
};
- readPairingUrls();
- const interval = setInterval(readPairingUrls, 250);
+ readLaunchRequest();
+ const interval = setInterval(readLaunchRequest, 250);
return () => clearInterval(interval);
}, [pairingUrls.length]);
@@ -95,13 +124,38 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
const readRequestedScene = () => {
const value = getNativeShowcaseScene();
- if (value) setRequestedScene(value);
+ if (!value || requestedSceneRef.current === value) return;
+ requestedSceneRef.current = value;
+ // A native draw belongs only to the scene request that produced it. In
+ // particular, revisiting review must wait for its newly mounted surface.
+ clearShowcaseRenderSignal();
+ setRequestedScene(value);
};
readRequestedScene();
const interval = setInterval(readRequestedScene, 250);
return () => clearInterval(interval);
}, []);
+ // Captures pick a palette for both color schemes so the requested theme is
+ // used whichever system appearance the runner set on the device.
+ const themeApplied =
+ requestedTheme === null
+ ? themeRequestSettled
+ : themeIds.light === requestedTheme && themeIds.dark === requestedTheme;
+
+ useEffect(() => {
+ if (
+ !SHOWCASE_ENABLED ||
+ requestedTheme === null ||
+ themeApplied ||
+ // Writing before stored preferences load would be overwritten by them.
+ !appearancePreferencesReady
+ ) {
+ return;
+ }
+ setThemeIdForBothAppearances(requestedTheme);
+ }, [appearancePreferencesReady, requestedTheme, setThemeIdForBothAppearances, themeApplied]);
+
useEffect(() => {
if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return;
let cancelled = false;
@@ -219,17 +273,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
!hasFixture ||
// Never report a scene ready while the capture orientation is still
// being applied — a screenshot taken early has the wrong dimensions.
- !orientationSettled
+ !orientationSettled ||
+ // Likewise for the palette: an early screenshot shows the default theme.
+ !themeApplied ||
+ !isShowcaseNativeContentReady({ scene, themeId, renderSignal })
) {
setReadyScene(null);
return;
}
- // Review owns its readiness marker because route activation happens before
- // the VCS request is parsed and the native diff surface is mounted.
- if (scene === "review") {
- setReadyScene(null);
- return;
- }
if (scene === "terminal") Keyboard.dismiss();
let renderFrame: number | null = null;
@@ -247,7 +298,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
if (renderFrame !== null) cancelAnimationFrame(renderFrame);
if (readyFrame !== null) cancelAnimationFrame(readyFrame);
};
- }, [hasFixture, orientationSettled, requestedScene, scene]);
+ }, [hasFixture, orientationSettled, renderSignal, requestedScene, scene, themeApplied, themeId]);
if (!SHOWCASE_ENABLED || readyScene === null) return null;
diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts
index 07ca60cf5333..11618291932d 100644
--- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts
+++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts
@@ -1,5 +1,7 @@
import { requireOptionalNativeModule } from "expo";
+import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme";
+
export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const;
export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number];
@@ -8,6 +10,7 @@ export type ShowcaseOrientation = "portrait" | "landscape";
interface NativeShowcaseControls {
readonly getShowcasePairingUrl?: () => string | null;
readonly getShowcaseScene?: () => string | null;
+ readonly getShowcaseTheme?: () => string | null;
readonly getShowcaseOrientation?: () => string | null;
readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise;
readonly getInterfaceOrientation?: () => Promise;
@@ -56,6 +59,20 @@ export function getNativeShowcaseScene(): ShowcaseScene | null {
}
}
+/**
+ * Returns null when the runner requested no palette, which leaves the stored
+ * theme preference untouched. An unknown id also reads as null rather than
+ * silently falling back, so a capture never claims to show a theme it does not.
+ */
+export function getNativeShowcaseTheme(): MobileThemeId | null {
+ try {
+ const theme = nativeShowcaseControls()?.getShowcaseTheme?.()?.trim();
+ return MOBILE_THEME_IDS.find((candidate) => candidate === theme) ?? null;
+ } catch {
+ return null;
+ }
+}
+
export function prepareNativeShowcaseCapture(): void {
try {
nativeShowcaseControls()?.prepareShowcaseCapture?.();
diff --git a/apps/mobile/src/features/showcase/showcaseRenderSignal.test.ts b/apps/mobile/src/features/showcase/showcaseRenderSignal.test.ts
new file mode 100644
index 000000000000..fdf044e7722a
--- /dev/null
+++ b/apps/mobile/src/features/showcase/showcaseRenderSignal.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+
+import {
+ clearShowcaseRenderSignal,
+ getShowcaseRenderSignal,
+ isShowcaseNativeContentReady,
+ reportShowcaseSceneRendered,
+ subscribeToShowcaseRenderSignal,
+} from "./showcaseRenderSignal";
+
+afterEach(clearShowcaseRenderSignal);
+
+describe("showcase native content readiness", () => {
+ it("does not gate scenes whose content is rendered by React Native", () => {
+ expect(
+ isShowcaseNativeContentReady({ scene: "environments", themeId: "grove", renderSignal: null }),
+ ).toBe(true);
+ });
+
+ it("waits for the native review surface to draw the active theme", () => {
+ expect(
+ isShowcaseNativeContentReady({ scene: "review", themeId: "grove", renderSignal: null }),
+ ).toBe(false);
+ expect(
+ isShowcaseNativeContentReady({
+ scene: "review",
+ themeId: "grove",
+ renderSignal: { scene: "review", themeId: "ocean" },
+ }),
+ ).toBe(false);
+ expect(
+ isShowcaseNativeContentReady({
+ scene: "review",
+ themeId: "grove",
+ renderSignal: { scene: "review", themeId: "grove" },
+ }),
+ ).toBe(true);
+ });
+
+ it("clears a draw when the runner requests another scene", () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribeToShowcaseRenderSignal(listener);
+
+ reportShowcaseSceneRendered({ scene: "review", themeId: "iris" });
+ expect(getShowcaseRenderSignal()).toEqual({ scene: "review", themeId: "iris" });
+ clearShowcaseRenderSignal();
+ expect(getShowcaseRenderSignal()).toBeNull();
+ expect(listener).toHaveBeenCalledTimes(2);
+
+ unsubscribe();
+ });
+});
diff --git a/apps/mobile/src/features/showcase/showcaseRenderSignal.ts b/apps/mobile/src/features/showcase/showcaseRenderSignal.ts
new file mode 100644
index 000000000000..014f08f781dd
--- /dev/null
+++ b/apps/mobile/src/features/showcase/showcaseRenderSignal.ts
@@ -0,0 +1,39 @@
+import type { MobileThemeId } from "../../lib/mobileTheme";
+import type { ShowcaseScene } from "./nativeShowcaseScene";
+
+export type ShowcaseRenderSignal = Readonly<{
+ scene: ShowcaseScene;
+ themeId: MobileThemeId;
+}>;
+
+const listeners = new Set<() => void>();
+let renderSignal: ShowcaseRenderSignal | null = null;
+
+export function getShowcaseRenderSignal(): ShowcaseRenderSignal | null {
+ return renderSignal;
+}
+
+export function subscribeToShowcaseRenderSignal(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export function reportShowcaseSceneRendered(signal: ShowcaseRenderSignal): void {
+ renderSignal = signal;
+ for (const listener of listeners) listener();
+}
+
+export function clearShowcaseRenderSignal(): void {
+ if (renderSignal === null) return;
+ renderSignal = null;
+ for (const listener of listeners) listener();
+}
+
+export function isShowcaseNativeContentReady(input: {
+ readonly scene: ShowcaseScene;
+ readonly themeId: MobileThemeId;
+ readonly renderSignal: ShowcaseRenderSignal | null;
+}): boolean {
+ if (input.scene !== "review") return true;
+ return input.renderSignal?.scene === "review" && input.renderSignal.themeId === input.themeId;
+}
diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
index b205b4df72cc..37dec1fe4562 100644
--- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
+++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
@@ -7,18 +7,18 @@ import {
type LayoutChangeEvent,
type NativeSyntheticEvent,
type ViewProps,
- useColorScheme,
} from "react-native";
import { AppText as Text } from "../../components/AppText";
import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import {
getNativeTerminalHardwareKeyRevision,
resolveNativeTerminalSurfaceView,
} from "./nativeTerminalModule";
import {
buildGhosttyThemeConfig,
- getPierreTerminalTheme,
+ getMobileTerminalTheme,
type TerminalTheme,
} from "./terminalTheme";
import { terminalDebugLog } from "./terminalDebugLog";
@@ -60,8 +60,8 @@ function estimateGridSize(input: {
const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {
const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
const inputRef = useRef(null);
- const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
- const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
+ const { themeAppearance, themeId } = useAppearancePreferences();
+ const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
const statusLabel = props.isRunning
? "Native terminal unavailable. Using text fallback."
: "Open terminal to start a shell.";
@@ -173,8 +173,8 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
- const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
- const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
+ const { themeAppearance, themeId } = useAppearancePreferences();
+ const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
const { onInput, onResize } = props;
const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
const hasNativeSurface = Boolean(NativeTerminalSurfaceView);
@@ -215,7 +215,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
return (
({
cols: DEFAULT_TERMINAL_COLS,
@@ -214,13 +216,13 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
}
return (
-
-
+
+
-
+
Terminal
-
+
{nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"}
@@ -231,10 +233,10 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
) : null}
-
+
diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
index a80d90d82cd7..f370401e8ecc 100644
--- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
+++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
@@ -5,7 +5,7 @@ import { SymbolView } from "../../components/AppSymbol";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Platform, Pressable, View, useColorScheme } from "react-native";
+import { Platform, Pressable, View } from "react-native";
import {
KeyboardController,
KeyboardEvents,
@@ -44,7 +44,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail";
import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { TerminalSurface } from "./NativeTerminalSurface";
-import { getPierreTerminalTheme } from "./terminalTheme";
+import { getMobileTerminalTheme } from "./terminalTheme";
import { terminalDebugLog } from "./terminalDebugLog";
import {
getTerminalBufferReplayKey,
@@ -166,7 +166,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");
const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
- const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
const { state: workspaceState } = useWorkspaceState();
const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
const params = props.route.params;
@@ -186,6 +185,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
const {
isReady: hasResolvedFontPreference,
appearance,
+ themeAppearance: appearanceScheme,
+ themeId,
setTerminalFontSize,
} = useAppearancePreferences();
const fontSize = appearance.terminalFontSize;
@@ -466,7 +467,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
[selectedEnvironmentConnection?.environmentLabel],
);
- const terminalTheme = getPierreTerminalTheme(appearanceScheme);
+ const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme);
const usesNativeHeaderGlass = Platform.OS === "ios";
const pendingModifier =
pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
@@ -1228,6 +1229,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
onResize={handleResize}
style={{ flex: 1 }}
terminalKey={terminalKey}
+ theme={terminalTheme}
/>
diff --git a/apps/mobile/src/features/terminal/terminalTheme.test.ts b/apps/mobile/src/features/terminal/terminalTheme.test.ts
index 3bf37b2eaab1..24edb384bebb 100644
--- a/apps/mobile/src/features/terminal/terminalTheme.test.ts
+++ b/apps/mobile/src/features/terminal/terminalTheme.test.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vite-plus/test";
+import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/themePalettes";
-import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme";
+import { themeColorToNativeColor } from "../../lib/mobileTheme";
+
+import {
+ buildGhosttyThemeConfig,
+ getMobileTerminalTheme,
+ getPierreTerminalTheme,
+} from "./terminalTheme";
describe("getPierreTerminalTheme", () => {
it("returns the Pierre light terminal palette", () => {
@@ -22,6 +29,33 @@ describe("getPierreTerminalTheme", () => {
});
});
+describe("getMobileTerminalTheme", () => {
+ it("preserves the Pierre terminal for the default theme", () => {
+ for (const scheme of ["light", "dark"] as const) {
+ expect(getMobileTerminalTheme("t3-code", scheme)).toEqual(getPierreTerminalTheme(scheme));
+ }
+ });
+
+ it("applies the selected palette without replacing ANSI status colors", () => {
+ const standard = getMobileTerminalTheme("t3-code", "dark");
+ const ocean = getMobileTerminalTheme("ocean", "dark");
+
+ expect(ocean.background).not.toBe(standard.background);
+ expect(ocean.cursorForeground).not.toBe(standard.cursorForeground);
+ expect(ocean.palette).toEqual(standard.palette);
+ });
+
+ it("uses the canonical desktop terminal roles for built-in themes", () => {
+ const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === "ocean")!;
+ const colors = getThemeColorsForAppearance(theme, "dark")!;
+ const terminal = getMobileTerminalTheme("ocean", "dark");
+
+ expect(terminal.background).toBe(themeColorToNativeColor(colors.terminalBackground));
+ expect(terminal.foreground).toBe(themeColorToNativeColor(colors.terminalForeground));
+ expect(terminal.cursorForeground).toBe(themeColorToNativeColor(colors.terminalCursor));
+ });
+});
+
describe("buildGhosttyThemeConfig", () => {
it("serializes theme colors into a ghostty config file", () => {
const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark"));
diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts
index c5ebd10b6894..9a913022571d 100644
--- a/apps/mobile/src/features/terminal/terminalTheme.ts
+++ b/apps/mobile/src/features/terminal/terminalTheme.ts
@@ -1,3 +1,11 @@
+import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/themePalettes";
+
+import {
+ getMobileThemeVariables,
+ themeColorToNativeColor,
+ type MobileThemeId,
+} from "../../lib/mobileTheme";
+
export type TerminalAppearanceScheme = "light" | "dark";
export interface TerminalTheme {
@@ -70,6 +78,28 @@ export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): Termin
return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME;
}
+export function getMobileTerminalTheme(
+ themeId: MobileThemeId,
+ scheme: TerminalAppearanceScheme,
+): TerminalTheme {
+ const base = getPierreTerminalTheme(scheme);
+ if (themeId === "t3-code") return base;
+
+ const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
+ const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors;
+ const colors = getMobileThemeVariables(themeId, scheme);
+ const background = themeColorToNativeColor(palette.terminalBackground);
+ return {
+ ...base,
+ background,
+ foreground: themeColorToNativeColor(palette.terminalForeground),
+ mutedForeground: colors["--color-foreground-muted"],
+ border: colors["--color-border"],
+ cursorForeground: themeColorToNativeColor(palette.terminalCursor),
+ cursorBackground: background,
+ };
+}
+
export function buildGhosttyThemeConfig(theme: TerminalTheme): string {
const lines = [
`background = ${theme.background}`,
diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
index 17758721f0ab..0eea51719521 100644
--- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
+++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
@@ -2,11 +2,12 @@ import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
import { SymbolView } from "../../components/AppSymbol";
import { memo } from "react";
-import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native";
+import { Pressable, ScrollView, View, type ViewStyle } from "react-native";
import { AppText as Text } from "../../components/AppText";
import { GlassSurface } from "../../components/GlassSurface";
import { PierreEntryIcon } from "../../components/PierreEntryIcon";
+import { useThemeColor } from "../../lib/useThemeColor";
export type ComposerCommandItem =
| {
readonly id: string;
@@ -45,11 +46,8 @@ interface ComposerCommandPopoverProps {
readonly onSelect: (item: ComposerCommandItem) => void;
}
-function PopoverSurface(props: {
- readonly children: React.ReactNode;
- readonly isDarkMode: boolean;
- readonly style?: ViewStyle;
-}) {
+function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) {
+ const tintColor = useThemeColor("--color-glass-surface");
const baseStyle: ViewStyle = {
borderRadius: 16,
overflow: "hidden",
@@ -57,11 +55,7 @@ function PopoverSurface(props: {
};
return (
-
+
{props.children}
);
@@ -114,7 +108,8 @@ const CommandRow = memo(function CommandRow(props: {
readonly isLast: boolean;
}) {
const iconName = itemIcon(props.item);
- const iconColor = "#a1a1aa";
+ const iconColor = useThemeColor("--color-icon-subtle");
+ const borderColor = useThemeColor("--color-border");
return (
{props.item.type === "path" ? (
@@ -139,7 +134,7 @@ const CommandRow = memo(function CommandRow(props: {
{props.item.label}
{props.item.description ? (
-
+
{props.item.description}
) : null}
@@ -150,11 +145,10 @@ const CommandRow = memo(function CommandRow(props: {
export const ComposerCommandPopover = memo(function ComposerCommandPopover(
props: ComposerCommandPopoverProps,
) {
- const isDarkMode = useColorScheme() === "dark";
const label = groupLabel(props.triggerKind);
return (
-
+
{label ? (
diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx
index bc41570351c4..8aeadc95cb6c 100644
--- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx
+++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx
@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useEffect, useRef } from "react";
-import { ActivityIndicator, Pressable, StyleSheet, useColorScheme, View } from "react-native";
+import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native";
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -11,6 +11,7 @@ import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
import { useThemeColor } from "../../lib/useThemeColor";
import type { GitActionProgress } from "../../state/use-vcs-action-state";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220);
const OVERLAY_TOP_GAP = 8;
@@ -69,7 +70,8 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) {
const iconColor = useThemeColor("--color-icon");
const glassBorder = useThemeColor("--color-header-border");
const glassTint = useThemeColor("--color-glass-tint");
- const isDarkMode = useColorScheme() === "dark";
+ const { themeAppearance } = useAppearancePreferences();
+ const isDarkMode = themeAppearance === "dark";
const content = (
<>
diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
index 68bf0d05c59d..97bb2ab98291 100644
--- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
+++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
@@ -13,7 +13,6 @@ import {
Platform,
Pressable,
ScrollView,
- Switch,
TextInput,
View,
} from "react-native";
@@ -22,6 +21,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
+import { ThemedSwitch } from "../../components/ThemedSwitch";
import { cn } from "../../lib/cn";
import { useFontFamily } from "../../lib/useFontFamily";
import { useThemeColor } from "../../lib/useThemeColor";
@@ -97,7 +97,7 @@ function ToggleRow(props: {
{props.title}
- state.isVisible);
const controlsBottomPadding = Math.max(insets.bottom, 10);
const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8);
@@ -290,11 +292,12 @@ export function NewTaskDraftScreen(props: {
}, [props.pendingTaskId, cancelEditingPendingTask]);
const foregroundColor = useThemeColor("--color-foreground");
+ const sheetColor = String(useThemeColor("--color-sheet"));
const projectUnderlineColor = useThemeColor("--color-foreground-muted");
const regularFontFamily = useFontFamily("regular");
const bodyText = useScaledTextRole("body");
- const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
- const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
+ const sheetFadeOpaque = sheetColor;
+ const sheetFadeTransparent = themeColorWithAlpha(sheetColor, 0);
// A new navigation to this mounted screen delivers a fresh initialProjectRef
// reference — treat it as a new request and let it apply again.
diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index 55a4eed568d6..087a96ea424f 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -22,7 +22,6 @@ import {
Platform,
Pressable,
StyleSheet,
- useColorScheme,
View,
type ViewStyle,
} from "react-native";
@@ -35,6 +34,7 @@ import Animated, {
LinearTransition,
} from "react-native-reanimated";
import { useThemeColor } from "../../lib/useThemeColor";
+import { themeColorWithAlpha } from "../../lib/mobileTheme";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { scopedThreadKey } from "../../lib/scopedEntities";
@@ -57,6 +57,7 @@ import { ProviderIcon } from "../../components/ProviderIcon";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import type { RemoteClientConnectionState } from "../../lib/connection";
import {
insertRankedSearchResult,
@@ -144,11 +145,14 @@ export function ComposerSurface(props: {
/** Existing thread composers morph between pill and card layouts. */
readonly animateLayout?: boolean;
}) {
+ const cardColor = useThemeColor("--color-card-translucent");
+ const borderColor = useThemeColor("--color-border");
+ const shadowColor = useThemeColor("--color-primary-shadow");
// Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself
// (needed to clip content to the pill shape) would clip the shadow on iOS.
const shadowStyle: ViewStyle = {
borderRadius: props.style.borderRadius,
- shadowColor: "#000000",
+ shadowColor,
shadowOpacity: props.isDarkMode ? 0.35 : 0.12,
shadowRadius: 14,
shadowOffset: { width: 0, height: 6 },
@@ -163,9 +167,9 @@ export function ComposerSurface(props: {
{isReconnecting ? (
-
+
) : (
)}
@@ -266,7 +271,8 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
const navigation = useNavigation();
- const isDarkMode = useColorScheme() === "dark";
+ const { themeAppearance } = useAppearancePreferences();
+ const isDarkMode = themeAppearance === "dark";
const foregroundColor = useThemeColor("--color-foreground");
const bodyText = useScaledTextRole("body");
const fallbackInputRef = useRef(null);
@@ -335,8 +341,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
environmentLabel: props.environmentLabel,
threadSyncPhase: props.threadSyncPhase,
});
- const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)";
- const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)";
+ const toolbarSurface = String(useThemeColor("--color-card"));
+ const backdropSurface = String(useThemeColor("--color-screen"));
+ const toolbarFadeOpaque = themeColorWithAlpha(toolbarSurface, 0.95);
+ const toolbarFadeTransparent = themeColorWithAlpha(toolbarSurface, 0);
+ const backdropGradient = `linear-gradient(to bottom, ${themeColorWithAlpha(backdropSurface, 0)} 0%, ${themeColorWithAlpha(backdropSurface, 0.6)} 55%, ${themeColorWithAlpha(backdropSurface, 0.9)} 100%)`;
const selectedProviderStatus = useMemo(() => {
if (!props.serverConfig) return null;
return (
@@ -704,9 +713,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
style={[
StyleSheet.absoluteFill,
{
- experimental_backgroundImage: isDarkMode
- ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 55%, rgba(0,0,0,0.9) 100%)"
- : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)",
+ experimental_backgroundImage: backdropGradient,
},
]}
/>
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index fb859090ad5e..e234838394ba 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -32,7 +32,6 @@ import {
AppState,
Keyboard,
Platform,
- useColorScheme,
useWindowDimensions,
View,
type GestureResponderEvent,
@@ -53,6 +52,7 @@ import Animated, {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ControlPill } from "../../components/ControlPill";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import type { ComposerEditorHandle } from "../../components/ComposerEditor";
import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
@@ -539,7 +539,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}, [freeze, scrollMessageToEnd]);
const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
- const isDarkMode = useColorScheme() === "dark";
+ const { themeAppearance } = useAppearancePreferences();
+ const isDarkMode = themeAppearance === "dark";
const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
feedTouchStartRef.current = {
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index c5edb822ae5b..f00736772766 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -37,7 +37,6 @@ import {
StyleSheet,
Text as NativeText,
type ColorValue,
- useColorScheme,
useWindowDimensions,
View,
} from "react-native";
@@ -194,43 +193,6 @@ function MessageAttachmentImage(props: {
);
}
-const MARKDOWN_COLORS = {
- light: {
- body: "#111111",
- strong: "#000000",
- link: "#2563eb",
- blockquoteBorder: "rgba(0, 0, 0, 0.08)",
- blockquoteBackground: "rgba(0, 0, 0, 0.02)",
- codeBackground: "rgba(0, 0, 0, 0.04)",
- codeText: "#262626",
- inlineCodeText: "#5f6368",
- horizontalRule: "rgba(0, 0, 0, 0.08)",
- userBody: "#ffffff",
- userCodeBackground: "rgba(255, 255, 255, 0.22)",
- userCodeText: "#ffffff",
- userInlineCodeText: "rgba(255, 255, 255, 0.82)",
- userFenceBackground: "rgba(0, 0, 0, 0.16)",
- userFenceText: "#ffffff",
- },
- dark: {
- body: "#e5e5e5",
- strong: "#f5f5f5",
- link: "#60a5fa",
- blockquoteBorder: "rgba(255, 255, 255, 0.1)",
- blockquoteBackground: "rgba(255, 255, 255, 0.03)",
- codeBackground: "rgba(255, 255, 255, 0.06)",
- codeText: "#e5e5e5",
- inlineCodeText: "#b8bcc2",
- horizontalRule: "rgba(255, 255, 255, 0.08)",
- userBody: "#ffffff",
- userCodeBackground: "rgba(255, 255, 255, 0.18)",
- userCodeText: "#ffffff",
- userInlineCodeText: "rgba(255, 255, 255, 0.82)",
- userFenceBackground: "rgba(0, 0, 0, 0.28)",
- userFenceText: "#ffffff",
- },
-} as const;
-
const MARKDOWN_MONO_FONT = Platform.select({
ios: "ui-monospace",
android: "monospace",
@@ -426,14 +388,12 @@ function MarkdownCodeBlock(props: {
}
function useReviewCommentColors(): ReviewCommentColors {
- const colorScheme = useColorScheme();
- const isDark = colorScheme === "dark";
- const background = isDark ? "#151515" : "#ffffff";
- const border = isDark ? "#2a2a2a" : "#d7d7d7";
- const mutedBackground = isDark ? "#242424" : "#f2f2f2";
- const text = isDark ? "#f3f3f3" : "#111111";
- const mutedText = isDark ? "#8f8f8f" : "#666666";
- const codeBackground = isDark ? "#0f0f0f" : "#ffffff";
+ const background = useThemeColor("--color-card");
+ const border = useThemeColor("--color-border");
+ const mutedBackground = useThemeColor("--color-subtle");
+ const text = useThemeColor("--color-foreground");
+ const mutedText = useThemeColor("--color-foreground-muted");
+ const codeBackground = useThemeColor("--color-md-code-bg");
return useMemo(
() => ({
@@ -449,8 +409,7 @@ function useReviewCommentColors(): ReviewCommentColors {
}
function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {
- const colorScheme = useColorScheme();
- const { appearance } = useAppearancePreferences();
+ const { appearance, themeAppearance } = useAppearancePreferences();
const markdownFontSizes = useMemo(
() => resolveMarkdownFontSizes(appearance.baseFontSize),
[appearance.baseFontSize],
@@ -459,31 +418,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
() => resolveNativeMarkdownTypography(appearance.baseFontSize),
[appearance.baseFontSize],
);
- const themeMode = colorScheme === "dark" ? "dark" : "light";
- const colors = MARKDOWN_COLORS[themeMode];
+ const themeMode = themeAppearance;
+ const markdownBodyColor = String(useThemeColor("--color-md-body"));
+ const markdownStrongColor = String(useThemeColor("--color-md-strong"));
+ const markdownLinkColor = String(useThemeColor("--color-md-link"));
+ const markdownBlockquoteBg = String(useThemeColor("--color-md-blockquote-bg"));
+ const markdownBlockquoteBorder = String(useThemeColor("--color-md-blockquote-border"));
+ const markdownCodeBg = String(useThemeColor("--color-md-code-bg"));
+ const markdownCodeText = String(useThemeColor("--color-md-code-text"));
+ const markdownInlineCodeText = String(useThemeColor("--color-foreground-secondary"));
+ const markdownHrColor = String(useThemeColor("--color-md-hr"));
+ const markdownUserBodyColor = String(useThemeColor("--color-user-bubble-foreground"));
+ const markdownUserCodeBg = String(useThemeColor("--color-md-user-code-bg"));
+ const markdownUserCodeText = String(useThemeColor("--color-md-user-code-text"));
+ const markdownUserInlineCodeText = String(useThemeColor("--color-user-bubble-foreground-muted"));
+ const markdownUserFenceBg = String(useThemeColor("--color-md-user-fence-bg"));
+ const markdownUserFenceText = String(useThemeColor("--color-md-user-fence-text"));
const iconSubtleColor = String(useThemeColor("--color-icon-subtle"));
const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
+ const userBubbleSkillForeground = String(useThemeColor("--color-user-bubble-skill-foreground"));
const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted"));
const regularFontFamily = useFontFamily("regular");
const boldFontFamily = useFontFamily("bold");
return useMemo(() => {
- const markdownBodyColor = colors.body;
- const markdownStrongColor = colors.strong;
- const markdownLinkColor = colors.link;
- const markdownBlockquoteBg = colors.blockquoteBackground;
- const markdownBlockquoteBorder = colors.blockquoteBorder;
- const markdownCodeBg = colors.codeBackground;
- const markdownCodeText = colors.codeText;
- const markdownInlineCodeText = colors.inlineCodeText;
- const markdownHrColor = colors.horizontalRule;
- const markdownUserBodyColor = colors.userBody;
- const markdownUserCodeBg = colors.userCodeBackground;
- const markdownUserCodeText = colors.userCodeText;
- const markdownUserInlineCodeText = colors.userInlineCodeText;
- const markdownUserFenceBg = colors.userFenceBackground;
- const markdownUserFenceText = colors.userFenceText;
-
const baseTheme: PartialMarkdownTheme = {
colors: {
text: markdownBodyColor,
@@ -759,8 +717,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
codeColor: markdownUserCodeText,
codeBackgroundColor: markdownUserCodeBg,
codeBlockBackgroundColor: markdownUserFenceBg,
- fileTextColor: "#ffffff",
- skillTextColor: "#f0abfc",
+ fileTextColor: markdownUserBodyColor,
+ skillTextColor: userBubbleSkillForeground,
quoteMarkerColor: markdownUserBodyColor,
dividerColor: markdownUserBodyColor,
fontSize: nativeMarkdownTypography.fontSize,
@@ -807,15 +765,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
};
}, [
boldFontFamily,
- colors,
iconSubtleColor,
inlineSkillForeground,
+ markdownBlockquoteBg,
+ markdownBlockquoteBorder,
+ markdownBodyColor,
+ markdownCodeBg,
+ markdownCodeText,
markdownFontSizes,
+ markdownHrColor,
+ markdownInlineCodeText,
+ markdownLinkColor,
+ markdownStrongColor,
+ markdownUserBodyColor,
+ markdownUserCodeBg,
+ markdownUserCodeText,
+ markdownUserFenceBg,
+ markdownUserFenceText,
+ markdownUserInlineCodeText,
nativeMarkdownTypography,
onLinkPress,
regularFontFamily,
themeMode,
userBubbleForegroundMuted,
+ userBubbleSkillForeground,
]);
}
@@ -1141,8 +1114,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
readonly colors: ReviewCommentColors;
}) {
const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface();
- const colorScheme = useColorScheme();
- const appearanceScheme = colorScheme === "light" ? "light" : "dark";
+ const { themeAppearance: appearanceScheme, themeId } = useAppearancePreferences();
const NativeReviewDiffView = resolveNativeReviewDiffView();
const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]);
const parsedDiff = useMemo(
@@ -1155,8 +1127,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
[nativeReviewDiffData.rows],
);
const nativeReviewDiffTheme = useMemo(
- () => createNativeReviewDiffTheme(appearanceScheme),
- [appearanceScheme],
+ () => createNativeReviewDiffTheme(appearanceScheme, themeId),
+ [appearanceScheme, themeId],
);
const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]);
const nativeThemeJson = useMemo(
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 12e974fe830c..007778c0af82 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -15,7 +15,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
-import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native";
+import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -38,6 +38,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import {
hasCustomHomeListOptions,
PROJECT_SORT_OPTIONS,
@@ -102,6 +103,8 @@ function SidebarHeaderButtonGroup(props: {
readonly children: ReactNode;
readonly colorScheme: "light" | "dark";
}) {
+ const fallbackBackground = useThemeColor("--color-glass-surface");
+ const fallbackBorder = useThemeColor("--color-header-border");
if (isLiquidGlassSupported) {
return (
@@ -192,7 +193,7 @@ function ThreadNavigationSidebarPane(
props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },
) {
const insets = useSafeAreaInsets();
- const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
+ const { themeAppearance: colorScheme } = useAppearancePreferences();
const projects = useProjects();
const threads = useThreadShells();
const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
index 637b7b5ee188..673e4c9bdaa5 100644
--- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
@@ -27,7 +27,7 @@ import {
useState,
type ReactNode,
} from "react";
-import { Platform, Pressable, ScrollView, Switch, TextInput, View } from "react-native";
+import { Platform, Pressable, ScrollView, TextInput, View } from "react-native";
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -35,6 +35,7 @@ import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
import { ProviderIcon } from "../../components/ProviderIcon";
+import { ThemedSwitch } from "../../components/ThemedSwitch";
import { cn } from "../../lib/cn";
import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection } from "../../lib/providerOptions";
@@ -46,10 +47,6 @@ import {
nativeHeaderScrollEdgeEffects,
} from "../../native/StackHeader";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
-import {
- NATIVE_SHEET_SURFACE_COLOR,
- NATIVE_SHEET_SURFACE_CONTENT_STYLE,
-} from "../../native/sheet-surface";
import { useNewTaskFlow } from "./new-task-flow-provider";
import {
createNativeMailSearchToolbarItem,
@@ -270,7 +267,7 @@ function SwitchRow(props: {
)}
>
{props.label}
- ({
onClose: props.onClose,
@@ -1144,17 +1140,13 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)
initialRouteName="ThreadSettingsModels"
screenOptions={{
animation: "slide_from_right",
- contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE ?? {
- backgroundColor: nativeSheetBackground,
- },
+ contentStyle: { backgroundColor: solidSheetBackground },
gestureEnabled: true,
headerBackButtonDisplayMode: "minimal",
headerBackTitle: "",
headerShadowVisible: false,
headerStyle: {
- backgroundColor: (NATIVE_LIQUID_GLASS_SUPPORTED
- ? "transparent"
- : nativeSheetBackground) as unknown as string,
+ backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground,
},
headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
headerTintColor: foreground,
diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx
index b1afe594f966..0c33da436a57 100644
--- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx
+++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx
@@ -1,5 +1,5 @@
import { SymbolView } from "../../components/AppSymbol";
-import { Pressable, StyleSheet, useColorScheme } from "react-native";
+import { Pressable, StyleSheet } from "react-native";
import { useThemeColor } from "../../lib/useThemeColor";
@@ -15,10 +15,8 @@ export function SidebarFilterButton(props: {
}) {
const iconColor = useThemeColor("--color-foreground");
const pressedBackgroundColor = useThemeColor("--color-subtle");
- const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
- const idleBackgroundColor =
- colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)";
- const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
+ const idleBackgroundColor = useThemeColor("--color-glass-surface");
+ const borderColor = useThemeColor("--color-header-border");
return (
-
+
,
-) {
+function pullRequestTintColor(state: ThreadPr["state"], colorScheme: "light" | "dark") {
const dark = colorScheme === "dark";
switch (state) {
case "open":
@@ -440,7 +439,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
>["simultaneousWithExternalGesture"];
}) {
const { width: windowWidth } = useWindowDimensions();
- const colorScheme = useColorScheme();
+ const { themeAppearance: colorScheme } = useAppearancePreferences();
const compact = props.variant === "compact";
const selected = props.selected === true;
// Recycling-safe: resets when the list container is reused for another
@@ -453,6 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
const drawerColor = useThemeColor("--color-drawer");
const pressedBackgroundColor = useThemeColor("--color-subtle");
const selectedBackgroundColor = useThemeColor("--color-user-bubble");
+ const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground");
const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
props;
@@ -467,10 +467,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
);
const backgroundColor = compact ? screenColor : drawerColor;
- const effectivePressedBackground = selected ? "rgba(255,255,255,0.16)" : pressedBackgroundColor;
+ const effectivePressedBackground = selected
+ ? themeColorWithAlpha(String(selectedForegroundColor), 0.16)
+ : pressedBackgroundColor;
const effectiveStatus =
selected && status
- ? { ...status, pillClassName: "bg-white/20", textClassName: "text-white" }
+ ? {
+ ...status,
+ pillClassName: "bg-user-bubble-foreground/20",
+ textClassName: "text-user-bubble-foreground",
+ }
: status;
const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
@@ -538,11 +544,15 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
{pr.label}
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index 1c25f949ed7c..86d442dfed97 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -6,14 +6,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
-import {
- Alert,
- Platform,
- Pressable,
- useColorScheme,
- useWindowDimensions,
- View,
-} from "react-native";
+import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { SymbolView } from "../../components/AppSymbol";
@@ -27,6 +20,7 @@ import { useThemeColor } from "../../lib/useThemeColor";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { useThreadPr } from "../../state/use-thread-pr";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
import {
resolveThreadListV2SnoozeMenuSelection,
@@ -120,7 +114,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS
readonly onToggle: () => void;
readonly pane?: "screen" | "sidebar";
}) {
- const colorScheme = useColorScheme();
+ const { themeAppearance: colorScheme } = useAppearancePreferences();
return (
@@ -689,7 +683,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
{statusLabel?.label ?? timeLabel}
@@ -766,7 +762,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
{pr ? (
#{pr.label}
diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx
index 529adac1db33..a5adacb8d19b 100644
--- a/apps/mobile/src/features/threads/thread-work-log.tsx
+++ b/apps/mobile/src/features/threads/thread-work-log.tsx
@@ -1,12 +1,13 @@
import * as Haptics from "expo-haptics";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
-import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native";
+import { LayoutAnimation, Pressable, ScrollView, View } from "react-native";
import { AppText as Text } from "../../components/AppText";
import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
import { cn } from "../../lib/cn";
import type { ThreadFeedActivity } from "../../lib/threadActivity";
import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
+import { useThemeColor } from "../../lib/useThemeColor";
import Animated, { FadeIn } from "react-native-reanimated";
const WORK_LOG_LAYOUT_ANIMATION = {
@@ -127,8 +128,7 @@ export function ThreadWorkLog(props: {
readonly onCopyRow: (rowId: string, value: string) => void;
readonly onToggleRow: (rowId: string) => void;
}) {
- const colorScheme = useColorScheme();
- const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
+ const pressedBackground = useThemeColor("--color-subtle");
const rows = visibleWorkLogActivities(props.activities).map((activity) => ({
...activity,
detail: compactActivityDetail(activity.detail),
@@ -281,8 +281,7 @@ export function ThreadWorkGroupToggle(props: {
readonly onlyToolActivities: boolean;
readonly onToggle: () => void;
}) {
- const colorScheme = useColorScheme();
- const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
+ const pressedBackground = useThemeColor("--color-subtle");
const noun = props.onlyToolActivities
? props.hiddenCount === 1
? "tool call"
diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts
index 3e2d027a9e35..9a9ec5f2282d 100644
--- a/apps/mobile/src/features/usage/usageProviders.ts
+++ b/apps/mobile/src/features/usage/usageProviders.ts
@@ -1,5 +1,5 @@
import type { UsageProviderKind } from "@t3tools/contracts";
-import { useColorScheme } from "react-native";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
/**
* Series and table order. The chart stacks providers from the bottom in this
@@ -17,7 +17,7 @@ export const PROVIDER_LABEL: Record = {
* with the theme or its bars vanish against the matching background.
*/
export function useProviderColors(): Record {
- const scheme = useColorScheme();
+ const { themeAppearance: scheme } = useAppearancePreferences();
return {
claude: "#d97757",
codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
diff --git a/apps/mobile/src/lib/mobileDefaultTheme.ts b/apps/mobile/src/lib/mobileDefaultTheme.ts
new file mode 100644
index 000000000000..66afae46473b
--- /dev/null
+++ b/apps/mobile/src/lib/mobileDefaultTheme.ts
@@ -0,0 +1,139 @@
+import type { MobileThemeVariables } from "./mobileTheme";
+
+/** The existing T3 Code mobile palette, retained as the upgrade-safe default. */
+export const DEFAULT_MOBILE_THEME_VARIABLES = {
+ light: {
+ "--color-screen": "#f2f2f7",
+ "--color-sheet": "rgba(242, 242, 247, 0.98)",
+ "--color-sheet-solid": "#f2f2f7",
+ "--color-card": "#ffffff",
+ "--color-card-alt": "#f5f5f5",
+ "--color-card-translucent": "rgba(255, 255, 255, 0.8)",
+ "--color-foreground": "#262626",
+ "--color-foreground-secondary": "#525252",
+ "--color-foreground-muted": "#737373",
+ "--color-foreground-tertiary": "#8e8e93",
+ "--color-border": "rgba(0, 0, 0, 0.08)",
+ "--color-border-subtle": "rgba(0, 0, 0, 0.06)",
+ "--color-separator": "rgba(0, 0, 0, 0.04)",
+ "--color-subtle": "rgba(0, 0, 0, 0.04)",
+ "--color-subtle-strong": "rgba(0, 0, 0, 0.08)",
+ "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)",
+ "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)",
+ "--color-inline-skill-foreground": "#a21caf",
+ "--color-primary": "#262626",
+ "--color-primary-foreground": "#ffffff",
+ "--color-primary-shadow": "#000000",
+ "--color-secondary": "#ffffff",
+ "--color-secondary-foreground": "#262626",
+ "--color-secondary-border": "rgba(0, 0, 0, 0.08)",
+ "--color-switch-active-track": "#34c759",
+ "--color-switch-active-thumb": "#ffffff",
+ "--color-switch-inactive-track": "rgba(0, 0, 0, 0.08)",
+ "--color-switch-inactive-thumb": "#8e8e93",
+ "--color-danger": "#fef2f2",
+ "--color-danger-border": "rgba(239, 68, 68, 0.12)",
+ "--color-danger-foreground": "#dc2626",
+ "--color-input": "#ffffff",
+ "--color-input-border": "rgba(0, 0, 0, 0.1)",
+ "--color-sidebar-search": "rgba(118, 118, 128, 0.12)",
+ "--color-placeholder": "#737373",
+ "--color-icon": "#262626",
+ "--color-icon-muted": "#525252",
+ "--color-icon-subtle": "#a3a3a3",
+ "--color-header": "rgba(255, 255, 255, 0.97)",
+ "--color-header-border": "rgba(0, 0, 0, 0.06)",
+ "--color-glass-surface": "rgba(255, 255, 255, 0.72)",
+ "--color-glass-tint": "rgba(255, 255, 255, 0.18)",
+ "--color-status-bar": "#f2f2f7",
+ "--color-md-body": "#111111",
+ "--color-md-strong": "#000000",
+ "--color-md-link": "#2563eb",
+ "--color-md-blockquote-border": "rgba(0, 0, 0, 0.08)",
+ "--color-md-blockquote-bg": "rgba(0, 0, 0, 0.02)",
+ "--color-md-code-bg": "rgba(0, 0, 0, 0.04)",
+ "--color-md-code-text": "#262626",
+ "--color-md-user-code-bg": "rgba(255, 255, 255, 0.22)",
+ "--color-md-user-code-text": "#ffffff",
+ "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.16)",
+ "--color-md-user-fence-text": "#ffffff",
+ "--color-md-hr": "rgba(0, 0, 0, 0.08)",
+ "--color-user-bubble": "#007aff",
+ "--color-user-bubble-foreground": "#ffffff",
+ "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)",
+ "--color-user-bubble-skill-foreground": "#f0abfc",
+ "--color-backdrop": "rgba(0, 0, 0, 0.22)",
+ "--color-drawer": "rgba(255, 255, 255, 0.99)",
+ "--color-drawer-shadow": "rgba(0, 0, 0, 0.12)",
+ "--color-dot-separator": "rgba(0, 0, 0, 0.2)",
+ "--color-wordmark": "#262626",
+ "--color-chevron": "rgba(0, 0, 0, 0.2)",
+ },
+ dark: {
+ "--color-screen": "#0a0a0a",
+ "--color-sheet": "rgba(14, 14, 14, 0.98)",
+ "--color-sheet-solid": "#0e0e0e",
+ "--color-card": "#171717",
+ "--color-card-alt": "#1c1c1c",
+ "--color-card-translucent": "rgba(17, 17, 17, 0.8)",
+ "--color-foreground": "#f5f5f5",
+ "--color-foreground-secondary": "#a3a3a3",
+ "--color-foreground-muted": "#8e8e93",
+ "--color-foreground-tertiary": "#636366",
+ "--color-border": "rgba(255, 255, 255, 0.06)",
+ "--color-border-subtle": "rgba(255, 255, 255, 0.04)",
+ "--color-separator": "rgba(255, 255, 255, 0.03)",
+ "--color-subtle": "rgba(255, 255, 255, 0.04)",
+ "--color-subtle-strong": "rgba(255, 255, 255, 0.08)",
+ "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)",
+ "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)",
+ "--color-inline-skill-foreground": "#f0abfc",
+ "--color-primary": "#f5f5f5",
+ "--color-primary-foreground": "#0a0a0a",
+ "--color-primary-shadow": "#000000",
+ "--color-secondary": "rgba(255, 255, 255, 0.04)",
+ "--color-secondary-foreground": "#f5f5f5",
+ "--color-secondary-border": "rgba(255, 255, 255, 0.06)",
+ "--color-switch-active-track": "#30d158",
+ "--color-switch-active-thumb": "#ffffff",
+ "--color-switch-inactive-track": "rgba(255, 255, 255, 0.06)",
+ "--color-switch-inactive-thumb": "#8e8e93",
+ "--color-danger": "rgba(239, 68, 68, 0.14)",
+ "--color-danger-border": "rgba(248, 113, 113, 0.18)",
+ "--color-danger-foreground": "#fca5a5",
+ "--color-input": "#141414",
+ "--color-input-border": "rgba(255, 255, 255, 0.08)",
+ "--color-sidebar-search": "rgba(118, 118, 128, 0.24)",
+ "--color-placeholder": "#8e8e93",
+ "--color-icon": "#f5f5f5",
+ "--color-icon-muted": "#a3a3a3",
+ "--color-icon-subtle": "#8e8e93",
+ "--color-header": "rgba(10, 10, 10, 0.97)",
+ "--color-header-border": "rgba(255, 255, 255, 0.06)",
+ "--color-glass-surface": "rgba(23, 23, 23, 0.78)",
+ "--color-glass-tint": "rgba(23, 23, 23, 0.24)",
+ "--color-status-bar": "#0a0a0a",
+ "--color-md-body": "#e5e5e5",
+ "--color-md-strong": "#f5f5f5",
+ "--color-md-link": "#60a5fa",
+ "--color-md-blockquote-border": "rgba(255, 255, 255, 0.1)",
+ "--color-md-blockquote-bg": "rgba(255, 255, 255, 0.03)",
+ "--color-md-code-bg": "rgba(255, 255, 255, 0.06)",
+ "--color-md-code-text": "#e5e5e5",
+ "--color-md-user-code-bg": "rgba(255, 255, 255, 0.18)",
+ "--color-md-user-code-text": "#ffffff",
+ "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.28)",
+ "--color-md-user-fence-text": "#ffffff",
+ "--color-md-hr": "rgba(255, 255, 255, 0.08)",
+ "--color-user-bubble": "#0a84ff",
+ "--color-user-bubble-foreground": "#ffffff",
+ "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)",
+ "--color-user-bubble-skill-foreground": "#f0abfc",
+ "--color-backdrop": "rgba(0, 0, 0, 0.48)",
+ "--color-drawer": "rgba(14, 14, 14, 0.99)",
+ "--color-drawer-shadow": "rgba(0, 0, 0, 0.32)",
+ "--color-dot-separator": "rgba(255, 255, 255, 0.2)",
+ "--color-wordmark": "#f5f5f5",
+ "--color-chevron": "rgba(255, 255, 255, 0.2)",
+ },
+} as const satisfies Readonly>;
diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts
new file mode 100644
index 000000000000..d5744952bba4
--- /dev/null
+++ b/apps/mobile/src/lib/mobileTheme.test.ts
@@ -0,0 +1,231 @@
+import { describe, expect, it } from "vite-plus/test";
+import * as NodeFS from "node:fs";
+
+import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
+import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme";
+
+import {
+ createMobileThemePairPatch,
+ createMobileThemeSelectionPatch,
+ createMobileThemeVariables,
+ DEFAULT_MOBILE_THEME_ID,
+ getMobileThemePreviewColors,
+ getMobileThemeVariables,
+ MOBILE_THEME_IDS,
+ normalizeMobileThemeId,
+ normalizeMobileThemeMode,
+ resolveMobileThemeIds,
+ themeColorWithAlpha,
+ themeColorToNativeColor,
+} from "./mobileTheme";
+
+function relativeLuminance(hex: string): number {
+ const channels = hex
+ .slice(1)
+ .match(/.{2}/g)!
+ .map((channel) => Number.parseInt(channel, 16) / 255)
+ .map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
+ return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
+}
+
+function contrastRatio(first: string, second: string): number {
+ const firstLuminance = relativeLuminance(first);
+ const secondLuminance = relativeLuminance(second);
+ return (
+ (Math.max(firstLuminance, secondLuminance) + 0.05) /
+ (Math.min(firstLuminance, secondLuminance) + 0.05)
+ );
+}
+
+function compositeOver(overlay: string, background: string): string {
+ const overlayMatch = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(overlay)!;
+ const backgroundChannels = background
+ .slice(1)
+ .match(/.{2}/g)!
+ .map((channel) => Number.parseInt(channel, 16));
+ const alpha = Number(overlayMatch[4]);
+ const channels = [1, 2, 3].map((index) =>
+ Math.round(Number(overlayMatch[index]) * alpha + backgroundChannels[index - 1]! * (1 - alpha)),
+ );
+ return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
+}
+
+describe("mobile themes", () => {
+ it("declares every runtime theme variable in the static stylesheet", () => {
+ const stylesheet = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8");
+ const stylesheetVariables = new Set(
+ Array.from(stylesheet.matchAll(/--color-[a-z0-9-]+/g), ([variable]) => variable),
+ );
+
+ expect(Array.from(stylesheetVariables).sort()).toEqual(
+ Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort(),
+ );
+ });
+
+ it("shares all built-in desktop palettes", () => {
+ expect(BUILT_IN_THEMES.map((theme) => theme.id)).toEqual(BUILT_IN_THEME_IDS);
+ for (const themeId of BUILT_IN_THEME_IDS) {
+ expect(getMobileThemeVariables(themeId, "light")["--color-screen"]).toMatch(/^#/);
+ expect(getMobileThemeVariables(themeId, "dark")["--color-screen"]).toMatch(/^#/);
+ }
+ });
+
+ it("preserves the existing mobile palette as the default", () => {
+ expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")["--color-screen"]).toBe(
+ "#f2f2f7",
+ );
+ expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "dark")["--color-screen"]).toBe(
+ "#0a0a0a",
+ );
+ expect(
+ getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")[
+ "--color-user-bubble-skill-foreground"
+ ],
+ ).toBe("#f0abfc");
+ });
+
+ it("applies palette overrides on top of the selected built-in theme", () => {
+ const variables = getMobileThemeVariables("ocean", "dark", {
+ "--color-primary": "#123456",
+ });
+
+ expect(variables["--color-primary"]).toBe("#123456");
+ expect(variables["--color-screen"]).toMatch(/^#/);
+ });
+
+ it("uses the same preview roles and standard artwork as desktop", () => {
+ expect(getMobileThemePreviewColors(DEFAULT_MOBILE_THEME_ID, "light")).toEqual({
+ canvas: "#fcfcfc",
+ accent: "#f4f4f5",
+ messageAction: "#4f46e5",
+ });
+ const desktopOcean = BUILT_IN_THEMES.find((theme) => theme.id === "ocean")!;
+ expect(getMobileThemePreviewColors("ocean", "light")).toEqual({
+ canvas: themeColorToNativeColor(desktopOcean.colors.canvas),
+ accent: themeColorToNativeColor(desktopOcean.colors.accent),
+ messageAction: themeColorToNativeColor(desktopOcean.colors.messageAction),
+ });
+ });
+
+ it("normalizes persisted theme preferences", () => {
+ expect(normalizeMobileThemeId("ocean")).toBe("ocean");
+ expect(normalizeMobileThemeId("missing-theme")).toBe(DEFAULT_MOBILE_THEME_ID);
+ expect(normalizeMobileThemeMode("dark")).toBe("dark");
+ expect(normalizeMobileThemeMode("sepia")).toBe("system");
+ });
+
+ it("migrates one theme choice to both appearances and preserves independent choices", () => {
+ expect(resolveMobileThemeIds({ themeId: "grove" })).toEqual({
+ light: "grove",
+ dark: "grove",
+ });
+ expect(
+ resolveMobileThemeIds({ themeId: "grove", lightThemeId: "iris", darkThemeId: "ocean" }),
+ ).toEqual({ light: "iris", dark: "ocean" });
+ expect(resolveMobileThemeIds({ themeId: "grove", lightThemeId: "missing" })).toEqual({
+ light: DEFAULT_MOBILE_THEME_ID,
+ dark: "grove",
+ });
+ });
+
+ it("changes either theme without switching the active appearance", () => {
+ const themeIds = { light: "t3-chat", dark: "grove" } as const;
+ expect(createMobileThemeSelectionPatch(themeIds, "light", "dark", "ocean")).toEqual({
+ lightThemeId: "t3-chat",
+ darkThemeId: "ocean",
+ themeId: "t3-chat",
+ });
+ expect(createMobileThemeSelectionPatch(themeIds, "light", "light", "iris")).toEqual({
+ lightThemeId: "iris",
+ darkThemeId: "grove",
+ themeId: "iris",
+ });
+ });
+
+ it("changes both appearance themes from the card action", () => {
+ expect(createMobileThemePairPatch("ember")).toEqual({
+ lightThemeId: "ember",
+ darkThemeId: "ember",
+ themeId: "ember",
+ });
+ });
+
+ it("converts OKLCH colors to React Native sRGB ColorValues", () => {
+ expect(themeColorToNativeColor("oklch(1 0 0)")).toBe("#ffffff");
+ expect(themeColorToNativeColor("oklch(0 0 0)")).toBe("#000000");
+ expect(themeColorToNativeColor("#123456")).toBe("#123456");
+ });
+
+ it("changes native palette color opacity for fades", () => {
+ expect(themeColorWithAlpha("#123456", 0)).toBe("rgba(18, 52, 86, 0)");
+ expect(themeColorWithAlpha("rgba(18, 52, 86, 0.98)", 0)).toBe("rgba(18, 52, 86, 0)");
+ });
+
+ it("maps semantic palette roles onto every mobile color variable", () => {
+ const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light");
+ expect(Object.keys(variables)).toHaveLength(65);
+ expect(variables["--color-sheet-solid"]).toBe(
+ themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome),
+ );
+ expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]);
+ expect(variables["--color-primary-shadow"]).toBe("#000000");
+ expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)");
+ expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)");
+ expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/);
+ expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort()).toEqual(
+ Object.keys(variables).sort(),
+ );
+ expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.dark).sort()).toEqual(
+ Object.keys(variables).sort(),
+ );
+ });
+
+ it("keeps every built-in shadow and backdrop black-based in dark mode", () => {
+ for (const theme of BUILT_IN_THEMES) {
+ const variables = getMobileThemeVariables(normalizeMobileThemeId(theme.id), "dark");
+ expect(variables["--color-primary-shadow"]).toBe("#000000");
+ expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)");
+ expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)");
+ }
+ });
+
+ it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => {
+ for (const themeId of MOBILE_THEME_IDS) {
+ for (const appearance of ["light", "dark"] as const) {
+ const variables = getMobileThemeVariables(themeId, appearance);
+ expect(
+ contrastRatio(variables["--color-placeholder"], variables["--color-input"]),
+ ).toBeGreaterThanOrEqual(4.5);
+ }
+ }
+
+ for (const themeId of BUILT_IN_THEME_IDS) {
+ for (const appearance of ["light", "dark"] as const) {
+ const variables = getMobileThemeVariables(themeId, appearance);
+ expect(
+ contrastRatio(
+ variables["--color-user-bubble-foreground"],
+ variables["--color-user-bubble"],
+ ),
+ ).toBeGreaterThanOrEqual(4.5);
+ expect(
+ contrastRatio(
+ variables["--color-user-bubble-skill-foreground"],
+ variables["--color-user-bubble"],
+ ),
+ ).toBeGreaterThanOrEqual(4.5);
+ expect(variables["--color-user-bubble-skill-foreground"]).not.toBe(
+ variables["--color-user-bubble-foreground"],
+ );
+ const fenceSurface = compositeOver(
+ variables["--color-md-user-fence-bg"],
+ variables["--color-user-bubble"],
+ );
+ expect(fenceSurface).not.toBe(variables["--color-user-bubble"]);
+ expect(
+ contrastRatio(variables["--color-md-user-fence-text"], fenceSurface),
+ ).toBeGreaterThanOrEqual(4.5);
+ }
+ }
+ });
+});
diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts
new file mode 100644
index 000000000000..36de7f979da6
--- /dev/null
+++ b/apps/mobile/src/lib/mobileTheme.ts
@@ -0,0 +1,314 @@
+import {
+ BUILT_IN_THEMES,
+ getThemeColorsForAppearance,
+ MOBILE_DEFAULT_THEME_ID,
+ MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS,
+ type MobileThemeId as SharedMobileThemeId,
+ type ThemeAppearance,
+ type ThemeColors,
+} from "@t3tools/shared/themePalettes";
+import {
+ STANDARD_THEME_PREVIEW_COLORS,
+ type ThemePreviewColors,
+} from "@t3tools/shared/themePreview";
+import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme";
+
+export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID;
+export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS;
+export type MobileThemeId = SharedMobileThemeId;
+export type MobileThemeAppearance = ThemeAppearance;
+export type MobileThemeMode = MobileThemeAppearance | "system";
+export type MobileThemeIds = Readonly>;
+
+export const MOBILE_THEME_OPTIONS: ReadonlyArray<{
+ readonly id: MobileThemeId;
+ readonly label: string;
+}> = [
+ { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" },
+ ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })),
+];
+
+type MobileThemeVariable = `--color-${string}`;
+export type MobileThemeVariables = Readonly>;
+
+export function normalizeMobileThemeId(value: unknown): MobileThemeId {
+ return typeof value === "string" && (MOBILE_THEME_IDS as readonly string[]).includes(value)
+ ? (value as MobileThemeId)
+ : DEFAULT_MOBILE_THEME_ID;
+}
+
+export function normalizeMobileThemeMode(value: unknown): MobileThemeMode {
+ return value === "light" || value === "dark" || value === "system" ? value : "system";
+}
+
+export function resolveMobileThemeIds(preferences: {
+ readonly themeId?: unknown;
+ readonly lightThemeId?: unknown;
+ readonly darkThemeId?: unknown;
+}): MobileThemeIds {
+ const legacyThemeId = normalizeMobileThemeId(preferences.themeId);
+ return {
+ light:
+ preferences.lightThemeId === undefined
+ ? legacyThemeId
+ : normalizeMobileThemeId(preferences.lightThemeId),
+ dark:
+ preferences.darkThemeId === undefined
+ ? legacyThemeId
+ : normalizeMobileThemeId(preferences.darkThemeId),
+ };
+}
+
+export function createMobileThemeSelectionPatch(
+ themeIds: MobileThemeIds,
+ activeAppearance: MobileThemeAppearance,
+ selectedAppearance: MobileThemeAppearance,
+ value: MobileThemeId,
+) {
+ const nextThemeIds: MobileThemeIds = {
+ light: selectedAppearance === "light" ? value : themeIds.light,
+ dark: selectedAppearance === "dark" ? value : themeIds.dark,
+ };
+ return {
+ lightThemeId: nextThemeIds.light,
+ darkThemeId: nextThemeIds.dark,
+ // Keep older OTA bundles on the theme for the appearance currently in use.
+ themeId: nextThemeIds[activeAppearance],
+ };
+}
+
+export function createMobileThemePairPatch(value: MobileThemeId) {
+ return {
+ lightThemeId: value,
+ darkThemeId: value,
+ themeId: value,
+ };
+}
+
+const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/;
+
+function linearToSrgb(value: number): number {
+ const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
+ return Math.round(Math.min(1, Math.max(0, converted)) * 255);
+}
+
+/** React Native does not accept OKLCH ColorValues, so palettes cross the app boundary as sRGB. */
+export function themeColorToNativeColor(value: string): string {
+ const match = OKLCH_PATTERN.exec(value);
+ if (!match) return value;
+
+ const lightness = Number(match[1]);
+ const chroma = Number(match[2]);
+ const hue = (Number(match[3]) * Math.PI) / 180;
+ const alpha = match[4] === undefined ? 1 : Number(match[4]);
+ const a = chroma * Math.cos(hue);
+ const b = chroma * Math.sin(hue);
+ const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b;
+ const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b;
+ const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b;
+ const l = lPrime ** 3;
+ const m = mPrime ** 3;
+ const s = sPrime ** 3;
+ const red = linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
+ const green = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
+ const blue = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s);
+
+ return alpha < 1
+ ? `rgba(${red}, ${green}, ${blue}, ${Number(alpha.toFixed(4))})`
+ : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
+}
+
+function nativeColors(colors: ThemeColors): ThemeColors {
+ return Object.fromEntries(
+ Object.entries(colors).map(([role, color]) => [role, themeColorToNativeColor(color)]),
+ ) as ThemeColors;
+}
+
+function withAlpha(color: string, alpha: number): string {
+ const hex = color.startsWith("#") ? color.slice(1) : "";
+ if (hex.length !== 6) return color;
+ const [red, green, blue] = [0, 2, 4].map((offset) =>
+ Number.parseInt(hex.slice(offset, offset + 2), 16),
+ );
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
+}
+
+function rgbChannels(color: string): readonly [number, number, number] | null {
+ const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color);
+ return match
+ ? [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)]
+ : null;
+}
+
+function relativeLuminance(channels: readonly [number, number, number]): number {
+ const [red, green, blue] = channels.map((channel) => {
+ const value = channel / 255;
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!;
+}
+
+function contrastRatio(
+ first: readonly [number, number, number],
+ second: readonly [number, number, number],
+): number {
+ const firstLuminance = relativeLuminance(first);
+ const secondLuminance = relativeLuminance(second);
+ return (
+ (Math.max(firstLuminance, secondLuminance) + 0.05) /
+ (Math.min(firstLuminance, secondLuminance) + 0.05)
+ );
+}
+
+/** Preserve the theme's action hue while making it readable as skill text on a message bubble. */
+function readableMessageAccent(accent: string, surface: string): string {
+ const accentChannels = rgbChannels(accent);
+ const surfaceChannels = rgbChannels(surface);
+ if (
+ !accentChannels ||
+ !surfaceChannels ||
+ contrastRatio(accentChannels, surfaceChannels) >= 4.5
+ ) {
+ return accent;
+ }
+
+ const black = [0, 0, 0] as const;
+ const white = [255, 255, 255] as const;
+ const target =
+ contrastRatio(black, surfaceChannels) >= contrastRatio(white, surfaceChannels) ? black : white;
+ let readable: readonly [number, number, number] = target;
+ let lowerAmount = 0;
+ let upperAmount = 1;
+ for (let index = 0; index < 12; index += 1) {
+ const amount = (lowerAmount + upperAmount) / 2;
+ const candidate: readonly [number, number, number] = [
+ Math.round(accentChannels[0] + (target[0] - accentChannels[0]) * amount),
+ Math.round(accentChannels[1] + (target[1] - accentChannels[1]) * amount),
+ Math.round(accentChannels[2] + (target[2] - accentChannels[2]) * amount),
+ ];
+ if (contrastRatio(candidate, surfaceChannels) >= 4.5) {
+ readable = candidate;
+ upperAmount = amount;
+ } else {
+ lowerAmount = amount;
+ }
+ }
+ return `#${readable.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
+}
+
+export function themeColorWithAlpha(color: string, alpha: number): string {
+ const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color);
+ if (hex) {
+ return `rgba(${Number.parseInt(hex[1], 16)}, ${Number.parseInt(hex[2], 16)}, ${Number.parseInt(hex[3], 16)}, ${alpha})`;
+ }
+ const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/.exec(color);
+ return rgb ? `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})` : color;
+}
+
+export function createMobileThemeVariables(
+ colors: ThemeColors,
+ appearance: MobileThemeAppearance,
+): MobileThemeVariables {
+ const c = nativeColors(colors);
+ return {
+ "--color-screen": c.canvas,
+ "--color-sheet": withAlpha(c.chrome, 0.98),
+ "--color-sheet-solid": c.chrome,
+ "--color-card": c.surfaceRaised,
+ "--color-card-alt": c.surface,
+ "--color-card-translucent": withAlpha(c.surfaceRaised, 0.8),
+ "--color-foreground": c.text,
+ "--color-foreground-secondary": c.textMuted,
+ "--color-foreground-muted": c.mutedForeground,
+ "--color-foreground-tertiary": c.secondaryLabel,
+ "--color-border": c.border,
+ "--color-border-subtle": withAlpha(c.border, 0.7),
+ "--color-separator": withAlpha(c.border, 0.55),
+ "--color-subtle": c.muted,
+ "--color-subtle-strong": c.secondary,
+ "--color-inline-skill-background": c.accentSurface,
+ "--color-inline-skill-border": withAlpha(c.accent, 0.42),
+ "--color-inline-skill-foreground": c.accentSurfaceForeground,
+ "--color-primary": c.accent,
+ "--color-primary-foreground": c.accentForeground,
+ "--color-primary-shadow": "#000000",
+ "--color-secondary": c.secondary,
+ "--color-secondary-foreground": c.secondaryForeground,
+ "--color-secondary-border": c.border,
+ "--color-switch-active-track": c.accent,
+ "--color-switch-active-thumb": c.accentForeground,
+ "--color-switch-inactive-track": c.secondary,
+ "--color-switch-inactive-thumb": c.mutedForeground,
+ "--color-danger": c.errorSurface,
+ "--color-danger-border": withAlpha(c.error, 0.32),
+ "--color-danger-foreground": c.errorForeground,
+ "--color-input": c.surfaceRaised,
+ "--color-input-border": c.input,
+ "--color-sidebar-search": c.sidebarControlSurface,
+ "--color-placeholder": c.placeholder,
+ "--color-icon": c.text,
+ "--color-icon-muted": c.iconMuted,
+ "--color-icon-subtle": c.secondaryLabel,
+ "--color-header": withAlpha(c.toolbar, 0.97),
+ "--color-header-border": c.toolbarBorder,
+ "--color-glass-surface": withAlpha(c.surfaceOverlay, 0.74),
+ "--color-glass-tint": withAlpha(c.surfaceOverlay, 0.22),
+ "--color-status-bar": c.canvas,
+ "--color-md-body": c.text,
+ "--color-md-strong": c.toolbarForeground,
+ "--color-md-link": c.accent,
+ "--color-md-blockquote-border": c.border,
+ "--color-md-blockquote-bg": c.muted,
+ "--color-md-code-bg": c.codeBackground,
+ "--color-md-code-text": c.codeForeground,
+ "--color-md-user-code-bg": withAlpha(c.messageForeground, 0.18),
+ "--color-md-user-code-text": c.messageForeground,
+ "--color-md-user-fence-bg": withAlpha("#000000", appearance === "dark" ? 0.28 : 0.16),
+ "--color-md-user-fence-text": c.messageForeground,
+ "--color-md-hr": c.border,
+ "--color-user-bubble": c.messageSurface,
+ "--color-user-bubble-foreground": c.messageForeground,
+ "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78),
+ "--color-user-bubble-skill-foreground": readableMessageAccent(
+ c.messageAction,
+ c.messageSurface,
+ ),
+ "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22),
+ "--color-drawer": withAlpha(c.sidebar, 0.99),
+ "--color-drawer-shadow": withAlpha("#000000", appearance === "dark" ? 0.32 : 0.12),
+ "--color-dot-separator": withAlpha(c.textMuted, 0.35),
+ "--color-wordmark": c.text,
+ "--color-chevron": withAlpha(c.textMuted, 0.42),
+ };
+}
+
+export function getMobileThemeVariables(
+ themeId: MobileThemeId,
+ appearance: MobileThemeAppearance,
+ overrides: Partial | null = null,
+): MobileThemeVariables {
+ const baseVariables = (() => {
+ if (themeId === DEFAULT_MOBILE_THEME_ID) return DEFAULT_MOBILE_THEME_VARIABLES[appearance];
+ const theme =
+ BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
+ const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
+ return createMobileThemeVariables(colors, appearance);
+ })();
+
+ // The complete base record guarantees that optional overrides cannot leave a token undefined.
+ return overrides ? ({ ...baseVariables, ...overrides } as MobileThemeVariables) : baseVariables;
+}
+
+export function getMobileThemePreviewColors(
+ themeId: MobileThemeId,
+ appearance: MobileThemeAppearance,
+): ThemePreviewColors {
+ if (themeId === DEFAULT_MOBILE_THEME_ID) return STANDARD_THEME_PREVIEW_COLORS[appearance];
+ const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
+ const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
+ return {
+ canvas: themeColorToNativeColor(colors.canvas),
+ accent: themeColorToNativeColor(colors.accent),
+ messageAction: themeColorToNativeColor(colors.messageAction),
+ };
+}
diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts
index a97252c7b72b..7b94dc629154 100644
--- a/apps/mobile/src/lib/storage.test.ts
+++ b/apps/mobile/src/lib/storage.test.ts
@@ -177,6 +177,25 @@ describe("mobile connection storage", () => {
await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
});
+ it("persists independent light and dark theme choices", async () => {
+ mocks.setPreferencesJson(
+ JSON.stringify({
+ themeId: "grove",
+ lightThemeId: "iris",
+ darkThemeId: "ocean",
+ themeMode: "system",
+ }),
+ 10,
+ );
+
+ await expect(loadPreferences()).resolves.toEqual({
+ themeId: "grove",
+ lightThemeId: "iris",
+ darkThemeId: "ocean",
+ themeMode: "system",
+ });
+ });
+
it("falls back to secure storage when SQLite cannot save preferences", async () => {
mocks.setDatabaseFailures(true, true);
await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });
diff --git a/apps/mobile/src/lib/useMobileNavigationTheme.ts b/apps/mobile/src/lib/useMobileNavigationTheme.ts
new file mode 100644
index 000000000000..6711f72c7435
--- /dev/null
+++ b/apps/mobile/src/lib/useMobileNavigationTheme.ts
@@ -0,0 +1,22 @@
+import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native";
+import { useMemo } from "react";
+
+import type { MobileThemeAppearance } from "./mobileTheme";
+import { useThemeColor } from "./useThemeColor";
+
+export function useMobileNavigationTheme(appearance: MobileThemeAppearance): Theme {
+ const primary = String(useThemeColor("--color-primary"));
+ const background = String(useThemeColor("--color-screen"));
+ const card = String(useThemeColor("--color-sheet-solid"));
+ const text = String(useThemeColor("--color-foreground"));
+ const border = String(useThemeColor("--color-header-border"));
+ const notification = String(useThemeColor("--color-danger-foreground"));
+
+ return useMemo(() => {
+ const base = appearance === "dark" ? DarkTheme : DefaultTheme;
+ return {
+ ...base,
+ colors: { ...base.colors, primary, background, card, text, border, notification },
+ };
+ }, [appearance, background, border, card, notification, primary, text]);
+}
diff --git a/apps/mobile/src/native/sheet-surface.ts b/apps/mobile/src/native/sheet-surface.ts
index eb2e8a8d1898..1b973b0ffc23 100644
--- a/apps/mobile/src/native/sheet-surface.ts
+++ b/apps/mobile/src/native/sheet-surface.ts
@@ -1,28 +1,7 @@
-import { DynamicColorIOS, Platform, type ColorValue, type ViewStyle } from "react-native";
-
/**
- * One opaque surface for content rendered inside a native form sheet.
- *
- * UIKit owns the outer sheet material and rounded corners. The presented route
- * owns this surface so nested navigators never expose a differently colored
- * native container while their screens move.
- */
-export const NATIVE_SHEET_SURFACE_COLOR: ColorValue | undefined =
- Platform.OS === "ios" ? DynamicColorIOS({ light: "#f2f2f7", dark: "#0e0e0e" }) : undefined;
-
-export const NATIVE_SHEET_SURFACE_CONTENT_STYLE: ViewStyle | undefined =
- NATIVE_SHEET_SURFACE_COLOR === undefined
- ? undefined
- : { backgroundColor: NATIVE_SHEET_SURFACE_COLOR };
-
-/**
- * Paint the adaptive background on the presented screen itself. Nested stacks
- * can stay transparent over this single surface, so a push never exposes an
- * unpainted form-sheet host behind the moving child view controllers.
+ * Form sheets inherit the live React Navigation palette supplied by App. Each
+ * presented route paints its content with bg-sheet, including nested pushes.
*/
export const FORM_SHEET_PRESENTATION_OPTIONS = {
presentation: "formSheet" as const,
- ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined
- ? null
- : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }),
};
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index 1da9c9f7ac6f..dfaeab9cd6ba 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -6,6 +6,7 @@ import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
+import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
import * as MobileDatabase from "./mobile-database";
import * as MobileSecureStorage from "./mobile-secure-storage";
@@ -16,6 +17,10 @@ const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";
export interface Preferences {
readonly liveActivitiesEnabled?: boolean;
+ readonly themeId?: MobileThemeId;
+ readonly lightThemeId?: MobileThemeId;
+ readonly darkThemeId?: MobileThemeId;
+ readonly themeMode?: MobileThemeMode;
readonly baseFontSize?: number;
readonly terminalFontSize?: number | null;
readonly markdownFontSize?: number;
@@ -79,6 +84,10 @@ export class MobilePreferencesStore extends Context.Service<
function sanitizePreferences(parsed: Preferences): Preferences {
const preferences: {
liveActivitiesEnabled?: boolean;
+ themeId?: MobileThemeId;
+ lightThemeId?: MobileThemeId;
+ darkThemeId?: MobileThemeId;
+ themeMode?: MobileThemeMode;
baseFontSize?: number;
terminalFontSize?: number | null;
markdownFontSize?: number;
@@ -96,6 +105,31 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.liveActivitiesEnabled === "boolean") {
preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
}
+ if (
+ typeof parsed.themeId === "string" &&
+ (MOBILE_THEME_IDS as readonly string[]).includes(parsed.themeId)
+ ) {
+ preferences.themeId = parsed.themeId as MobileThemeId;
+ }
+ if (
+ typeof parsed.lightThemeId === "string" &&
+ (MOBILE_THEME_IDS as readonly string[]).includes(parsed.lightThemeId)
+ ) {
+ preferences.lightThemeId = parsed.lightThemeId as MobileThemeId;
+ }
+ if (
+ typeof parsed.darkThemeId === "string" &&
+ (MOBILE_THEME_IDS as readonly string[]).includes(parsed.darkThemeId)
+ ) {
+ preferences.darkThemeId = parsed.darkThemeId as MobileThemeId;
+ }
+ if (
+ parsed.themeMode === "system" ||
+ parsed.themeMode === "light" ||
+ parsed.themeMode === "dark"
+ ) {
+ preferences.themeMode = parsed.themeMode;
+ }
if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;
if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
preferences.terminalFontSize = parsed.terminalFontSize;
diff --git a/apps/web/src/components/settings/ThemePreviewCircles.tsx b/apps/web/src/components/settings/ThemePreviewCircles.tsx
index 6c3f0afbda56..1ac8fa734921 100644
--- a/apps/web/src/components/settings/ThemePreviewCircles.tsx
+++ b/apps/web/src/components/settings/ThemePreviewCircles.tsx
@@ -1,5 +1,9 @@
import { MoonIcon, SunIcon } from "lucide-react";
import type { CSSProperties } from "react";
+import {
+ STANDARD_THEME_PREVIEW_COLORS as SHARED_STANDARD_THEME_PREVIEW_COLORS,
+ THEME_PREVIEW_RENDER_SPECS,
+} from "@t3tools/shared/themePreview";
import { cn } from "../../lib/utils";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import {
@@ -37,21 +41,17 @@ const STANDARD_THEME_PREVIEW_COLORS: Record<
> = {
light: {
sidebar: "#fafafa",
- canvas: "#fcfcfc",
surface: "#ffffff",
accentSurface: "#f4f4f5",
- accent: "#f4f4f5",
messageSurface: "#e4e4e7",
- messageAction: "#4f46e5",
+ ...SHARED_STANDARD_THEME_PREVIEW_COLORS.light,
},
dark: {
sidebar: "#0f0f10",
- canvas: "#0a0a0a",
surface: "#121212",
accentSurface: "#27272a",
- accent: "#1c1c1f",
messageSurface: "#27272a",
- messageAction: "#8b9cff",
+ ...SHARED_STANDARD_THEME_PREVIEW_COLORS.dark,
},
};
@@ -102,23 +102,20 @@ function getThemePreviewStyle(
colors: ThemeCardPreviewColors,
mode: ThemeAppearance,
): CSSProperties {
- const isDark = mode === "dark";
+ const spec = THEME_PREVIEW_RENDER_SPECS[mode];
// The canvas carries the ball's light/dark identity, so it stays dominant:
// a near-true base with a contained accent glow, instead of an accent wash
// that makes both modes read alike.
- const modeBase = isDark
- ? `color-mix(in oklab, ${colors.canvas} 80%, #09090b)`
- : `color-mix(in oklab, ${colors.canvas} 80%, #ffffff)`;
- const accentPosition = isDark ? "28% 78%" : "72% 22%";
- const actionPosition = isDark ? "82% 18%" : "18% 82%";
- const accentFade = isDark ? 62 : 72;
+ const modeBase = `color-mix(in oklab, ${colors.canvas} ${spec.baseWeight * 100}%, ${spec.baseTarget})`;
+ const accentPosition = `${spec.accent.center[0] * 100}% ${spec.accent.center[1] * 100}%`;
+ const actionPosition = `${spec.action.center[0] * 100}% ${spec.action.center[1] * 100}%`;
return {
backgroundColor: modeBase,
backgroundImage: [
- `radial-gradient(circle at ${accentPosition} in oklab, ${colors.accent} 0%, color-mix(in oklab, ${colors.accent} ${accentFade}%, transparent) 28%, transparent 58%)`,
+ `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}%)`,
// The action color is a soft tint from the opposite corner, not a second
// light source — two bright hotspots read as headlights.
- `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} 45%, transparent) 0%, transparent 55%)`,
+ `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} ${spec.action.startOpacity * 100}%, transparent) 0%, transparent ${spec.action.endOffset * 100}%)`,
].join(", "),
};
}
@@ -145,8 +142,12 @@ export function ThemePreviewCircle({
style={{ boxShadow: themePreviewEdgeShadow(mode) }}
>
);
diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts
index 3c5897c70152..a836f7e0c2eb 100644
--- a/apps/web/src/themePalette.test.ts
+++ b/apps/web/src/themePalette.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vite-plus/test";
+import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
import {
applyThemeColorPreview,
@@ -78,6 +79,16 @@ function contrastRatio(first: string, second: string): number {
}
describe("theme files", () => {
+ it("keeps every built-in palette value in canonical OKLCH form", () => {
+ for (const theme of BUILT_IN_THEMES) {
+ for (const colors of [theme.colors, ...Object.values(theme.variants ?? {})]) {
+ for (const value of Object.values(colors)) {
+ expect(toCanonicalThemeColor(value)).toBe(value);
+ }
+ }
+ }
+ });
+
it("derives a readable palette from extreme simple-editor colors", () => {
const light = createManagedThemeColors("light", "#111827", "#ffff00");
const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00");
diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts
index ec75cbe5b758..8402aeb2001b 100644
--- a/apps/web/src/themePalette.ts
+++ b/apps/web/src/themePalette.ts
@@ -1,6 +1,23 @@
import * as Schema from "effect/Schema";
import "culori/css";
import { converter, parse } from "culori/fn";
+import {
+ BUILT_IN_THEMES,
+ EMBER_THEME,
+ GROVE_THEME,
+ IRIS_THEME,
+ OCEAN_THEME,
+ T3_CHAT_THEME,
+ THEME_COLOR_ROLES,
+ type ThemeAppearance,
+ type ThemeColorRole,
+ type ThemeColors,
+ type ThemeDefinition,
+ type ThemeVariants,
+} from "@t3tools/shared/themePalettes";
+
+export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES };
+export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants };
export const T3_CHAT_THEME_ID = "t3-chat" as const;
export const T3_CHAT_THEME_LABEL = "T3 Chat";
@@ -23,90 +40,11 @@ const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark";
export const ThemePreference = Schema.String;
export type ThemePreference = typeof ThemePreference.Type;
-export const THEME_COLOR_ROLES = [
- "canvas",
- "chrome",
- "toolbar",
- "toolbarForeground",
- "toolbarBorder",
- "toolbarControl",
- "toolbarControlForeground",
- "toolbarControlHover",
- "surface",
- "surfaceRaised",
- "surfaceOverlay",
- "text",
- "textMuted",
- "border",
- "input",
- "focus",
- "accent",
- "accentForeground",
- "secondary",
- "secondaryForeground",
- "muted",
- "mutedForeground",
- "placeholder",
- "secondaryLabel",
- "iconMuted",
- "error",
- "errorForeground",
- "errorSurface",
- "warning",
- "warningForeground",
- "warningSurface",
- "update",
- "updateForeground",
- "updateSurface",
- "accentSurface",
- "accentSurfaceForeground",
- "messageSurface",
- "messageForeground",
- "messageAction",
- "messageActionForeground",
- "messageActionHover",
- "codeBackground",
- "codeForeground",
- "sidebar",
- "sidebarForeground",
- "sidebarMutedForeground",
- "sidebarControlSurface",
- "sidebarRowHover",
- "sidebarRowActive",
- "sidebarRowSelected",
- "sidebarBorder",
- "terminalBackground",
- "terminalForeground",
- "terminalCursor",
- "terminalSelection",
- "terminalScrollbar",
- "terminalScrollbarHover",
-] as const;
-
-export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number];
const THEME_COLOR_ROLE_SET: ReadonlySet = new Set(THEME_COLOR_ROLES);
-export type ThemeAppearance = "light" | "dark";
-
-export type ThemeColors = Readonly>;
export type ThemeColorOverrides = Readonly>>;
-export type ThemeVariants = Readonly>>;
export type ThemeVariantOverrides = Readonly>>;
export type ThemePreferenceMode = ThemeAppearance | "system";
export type ThemeCollection = Readonly<{ id: string; label: string }>;
-export type ThemeDefinition = Readonly<{
- id: string;
- label: string;
- appearance: ThemeAppearance;
- colors: ThemeColors;
- variants?: ThemeVariants;
- /** Groups related imported variants into one library card. */
- collection?: ThemeCollection;
- /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */
- sidebarArtwork?: boolean;
- /** True when the palette was generated by the guided editor from its
- * canvas and accent; such themes reopen in guided mode. */
- managed?: boolean;
-}>;
export type ThemeFile = Readonly<{
version: typeof THEME_FILE_VERSION;
id: string;
@@ -366,155 +304,6 @@ function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null {
return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null;
}
-/**
- * Maintainer palettes use product color roles rather than Tailwind or component
- * names so the same definitions can feed other clients and native surfaces.
- */
-// Measured from the live t3.chat default theme. Translucent chat surfaces are
-// flattened over --chat-background so this opaque palette reproduces the
-// pixels users see after T3 Chat's blur and noise layers are composited.
-// Foreground pairs deviate where necessary to keep normal text at WCAG AA.
-const T3_CHAT_LIGHT_COLORS: ThemeColors = {
- canvas: "#fdf7fd",
- // T3 Code's workspace header belongs to the chat panel, so keep it seamless
- // with the light chat canvas rather than mapping it to T3 Chat's outer shell.
- chrome: "#fdf7fd",
- toolbar: "#fdf7fd",
- toolbarForeground: "#501854",
- toolbarBorder: "#efbdeb",
- // T3 Chat's light chrome controls sit on its pale gradient-noise surface,
- // not the substantially darker solid accent token.
- toolbarControl: "#f3e6f5",
- toolbarControlForeground: "#501854",
- toolbarControlHover: "#eccfe3",
- surface: "#faf3fb",
- surfaceRaised: "#fdfafd",
- surfaceOverlay: "#ffffff",
- text: "#501854",
- textMuted: "#ac1668",
- border: "#eee1ed",
- input: "#e7c1dc",
- focus: "#db2777",
- accent: "#db2777",
- accentForeground: "#ffffff",
- secondary: "#f1c4e6",
- secondaryForeground: "#77347c",
- muted: "#eaa7cb",
- mutedForeground: "#8d1255",
- placeholder: "#8b5f90",
- secondaryLabel: "#ac1668",
- iconMuted: "#ac1668",
- error: "#f7086c",
- errorForeground: "#9d174d",
- errorSurface: "#fde4f1",
- warning: "#f59e0b",
- warningForeground: "#b05109",
- warningSurface: "#fcf0ea",
- update: "#db2777",
- updateForeground: "#ac1668",
- updateSurface: "#fadfef",
- accentSurface: "#f3e6f5",
- accentSurfaceForeground: "#454554",
- messageSurface: "#f7def2",
- messageForeground: "#492c61",
- messageAction: "#db2777",
- messageActionForeground: "#ffffff",
- messageActionHover: "#c12269",
- // T3 Chat uses a light lavender code surface in light mode. Keeping the
- // dark plum pair here also leaked the dark palette into T3 Code's diffs.
- codeBackground: "#f5ecf9",
- codeForeground: "#673c8b",
- // The live sidebar is transparent over T3 Chat's outer shell. Use that
- // rendered shell color rather than its unused, darker sidebar token.
- sidebar: "#f2e1f4",
- sidebarForeground: "#454554",
- sidebarMutedForeground: "#ac1668",
- sidebarControlSurface: "#f8f8f7",
- sidebarRowHover: "#f8f8f7",
- sidebarRowActive: "#f8f8f7",
- sidebarRowSelected: "#f8f8f7",
- sidebarBorder: "#eceae9",
- terminalBackground: "#fdf7fd",
- terminalForeground: "#501854",
- terminalCursor: "#db2777",
- terminalSelection: "#f1c4e6",
- terminalScrollbar: "#e7c1dc",
- terminalScrollbarHover: "#eaa7cb",
-};
-
-const T3_CHAT_DARK_COLORS: ThemeColors = {
- canvas: "#1f1a24",
- // T3 Code's workspace header belongs to the chat panel, so keep it seamless
- // with the canvas rather than mapping it to T3 Chat's outer shell.
- chrome: "#1f1a24",
- toolbar: "#1f1a24",
- toolbarForeground: "#f9f8fb",
- toolbarBorder: "#27242c",
- toolbarControl: "#362d3d",
- toolbarControlForeground: "#d4c7e1",
- toolbarControlHover: "#463753",
- // Cards and panels stay in T3 Chat's plum surface family. Near-black here
- // made the right-panel surface picker look unrelated to the chat canvas.
- surface: "#29232d",
- // Pre-composited for the composer's 80% glass layer; this resolves to the
- // measured #29232d input fill over the canvas.
- surfaceRaised: "#2c2631",
- surfaceOverlay: "#100a0e",
- text: "#f9f8fb",
- textMuted: "#e7d0dd",
- border: "#27242c",
- input: "#302029",
- focus: "#db2777",
- accent: "#a3004c",
- accentForeground: "#fbd0e8",
- secondary: "#362d3d",
- secondaryForeground: "#d4c7e1",
- muted: "#423a45",
- mutedForeground: "#e7d0dd",
- placeholder: "#968d9f",
- secondaryLabel: "#e7d0dd",
- iconMuted: "#d4c7e1",
- error: "#9d174d",
- errorForeground: "#fbd0e8",
- errorSurface: "#331a2b",
- warning: "#f59e0b",
- warningForeground: "#fbbf24",
- warningSurface: "#412f20",
- update: "#a3004c",
- updateForeground: "#fbd0e8",
- updateSurface: "#37152b",
- accentSurface: "#463753",
- accentSurfaceForeground: "#f8f1f5",
- messageSurface: "#2b2431",
- messageForeground: "#f2ebfa",
- messageAction: "#a3004c",
- messageActionForeground: "#fbd0e8",
- messageActionHover: "#a2004c",
- // Diffs and file previews are full workspace surfaces in T3 Code. Keep them
- // continuous with the themed canvas instead of dropping to near-black.
- codeBackground: "#1f1a24",
- codeForeground: "#d8c3ef",
- // The live sidebar starts from #131314, then gains its hue from a pink
- // gradient/noise stack. This pre-grain base lands on the same #1a131a
- // visible shell color after our surface-grain layer is composited.
- sidebar: "#171018",
- sidebarForeground: "#f4f4f5",
- sidebarMutedForeground: "#e7d0dd",
- sidebarControlSurface: "#261922",
- sidebarRowHover: "#261922",
- sidebarRowActive: "#261922",
- sidebarRowSelected: "#261922",
- // T3 Chat draws the chat panel edge in this muted pink. The resize rail uses
- // the same role on hover, so it stays pink instead of falling back to black.
- sidebarBorder: "#322028",
- terminalBackground: "#1f1a24",
- terminalForeground: "#f9f8fb",
- terminalCursor: "#db2777",
- terminalSelection: "#362d3d",
- terminalScrollbar: "#302029",
- terminalScrollbarHover: "#423a45",
-};
-
/**
* The palette T3 Code wears with no theme installed, captured from the app's
* stock tokens (index.css) so a draft seeded from the default look paints the
@@ -1431,43 +1220,11 @@ export function createManagedThemeColors(
};
}
-export const T3_CHAT_THEME: ThemeDefinition = {
- id: T3_CHAT_THEME_ID,
- label: T3_CHAT_THEME_LABEL,
- appearance: "light",
- colors: decodeThemeColors(T3_CHAT_LIGHT_COLORS),
- variants: {
- dark: decodeThemeColors(T3_CHAT_DARK_COLORS),
- },
- sidebarArtwork: true,
-};
-
/** Theme-file defaults follow the flagship palette for the requested mode. */
export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors {
return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors;
}
-/**
- * A companion action color in the T3 Chat mold. This gives send buttons,
- * status pills, and theme previews a second voice; foreground and hover follow
- * the same rules as the managed generator.
- */
-function themeActionColors(
- action: string,
-): Pick {
- const rgb = parseThemeRgbColor(action, THEME_DARK_FOREGROUND);
- const foreground = readableThemeForeground(rgb);
- const towardOpposite =
- foreground === THEME_LIGHT_FOREGROUND || foreground === THEME_WHITE_FOREGROUND
- ? THEME_BLACK_FOREGROUND
- : THEME_WHITE_FOREGROUND;
- return {
- messageAction: toCanonicalThemeColor(action) ?? themeRgbToThemeColor(rgb),
- messageActionForeground: themeRgbToThemeColor(foreground),
- messageActionHover: themeRgbToThemeColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)),
- };
-}
-
/**
* Update one Advanced-editor color family without normalizing the rest of an
* imported or hand-tuned palette. The editor exposes a representative role
@@ -1660,81 +1417,7 @@ export function updateThemeColorFamily(
}
}
-export const GROVE_THEME: ThemeDefinition = {
- id: GROVE_THEME_ID,
- label: GROVE_THEME_LABEL,
- appearance: "light",
- colors: {
- ...createManagedThemeColors("light", "#f2f8f4", "#19734a"),
- ...themeActionColors("#8f6410"),
- },
- variants: {
- dark: {
- ...createManagedThemeColors("dark", "#1d2b24", "#69d69a"),
- ...themeActionColors("#e3b34e"),
- },
- },
- sidebarArtwork: true,
-};
-
-export const OCEAN_THEME: ThemeDefinition = {
- id: OCEAN_THEME_ID,
- label: OCEAN_THEME_LABEL,
- appearance: "light",
- colors: {
- ...createManagedThemeColors("light", "#f2f7fb", "#2878b8"),
- ...themeActionColors("#0a6f75"),
- },
- variants: {
- dark: {
- ...createManagedThemeColors("dark", "#1b2938", "#70b9ee"),
- ...themeActionColors("#5bd0d6"),
- },
- },
- sidebarArtwork: true,
-};
-
-export const EMBER_THEME: ThemeDefinition = {
- id: EMBER_THEME_ID,
- label: EMBER_THEME_LABEL,
- appearance: "light",
- colors: {
- ...createManagedThemeColors("light", "#fff6ef", "#c4602f"),
- ...themeActionColors("#b23535"),
- },
- variants: {
- dark: {
- ...createManagedThemeColors("dark", "#30231e", "#f39a62"),
- ...themeActionColors("#f78a7a"),
- },
- },
- sidebarArtwork: true,
-};
-
-export const IRIS_THEME: ThemeDefinition = {
- id: IRIS_THEME_ID,
- label: IRIS_THEME_LABEL,
- appearance: "light",
- colors: {
- ...createManagedThemeColors("light", "#f7f4fc", "#7254b9"),
- ...themeActionColors("#a82c87"),
- },
- variants: {
- dark: {
- ...createManagedThemeColors("dark", "#29243b", "#ad92f5"),
- ...themeActionColors("#f099d8"),
- },
- },
- sidebarArtwork: true,
-};
-
-const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray = [
- T3_CHAT_THEME,
- GROVE_THEME,
- OCEAN_THEME,
- EMBER_THEME,
- IRIS_THEME,
-];
+const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray = BUILT_IN_THEMES;
export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null {
const themeId = themeIdFromPreference(theme);
diff --git a/docs/README.md b/docs/README.md
index 30653e7d5035..f1698a66e179 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -8,6 +8,7 @@
- [Organizing threads](./user/thread-sidebar.md)
- [Review usage](./user/usage.md)
- [Customize a project icon](./user/project-settings.md)
+- [Mobile appearance](./user/mobile-appearance.md)
- [Remote access](./user/remote-access.md)
- [Keeping app and server in sync](./user/updating.md)
- [Source control integrations](./user/source-control.md)
diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md
index f271e54098aa..0e3c1784429b 100644
--- a/docs/operations/mobile-app-store-screenshots.md
+++ b/docs/operations/mobile-app-store-screenshots.md
@@ -30,7 +30,7 @@ The command:
4. Starts an isolated Metro server, builds the selected native apps, and boots each device.
5. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin.
6. Navigates to the real application route for every requested scene.
-7. Sets the requested system appearance and normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and
+7. Sets the requested system appearance and palette, normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and
validates dimensions, aspect ratio, file size, and screenshot count before succeeding.
8. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded
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
verify process ownership. Concurrent screenshot harnesses in different worktrees can therefore
collide or attach to the wrong Metro process.
-Every configured device defaults to dark appearance, so plain `pnpm screenshots:mobile` produces
-30 dark PNGs. Pass `--appearance light`, `--appearance dark`, or `--appearance both` to override the
-configured appearance; `both` produces 60 PNGs.
+Every configured device defaults to dark appearance and the `t3-code` palette, so plain
+`pnpm screenshots:mobile` produces 30 dark PNGs. Pass `--appearance light`, `--appearance dark`, or
+`--appearance both` to override the configured appearance; `both` produces 60 PNGs.
+
+Pass `--theme ` (repeatable) or `--theme all` to capture the app's other palettes: `t3-code`,
+`t3-chat`, `grove`, `ocean`, `ember`, and `iris`. The runner hands the palette to the app as a launch
+argument, the app applies it to both color schemes, and a scene only reports itself ready once the
+requested palette is active — so a capture can never show the previous theme. `--theme all`
+multiplies the run by six; only the native build is shared.
The default matrix is:
-| Output folder | Capture target | Upload dimensions | Store slot |
-| ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- |
-| `apple/iphone-6.9/dark/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch |
-| `apple/iphone-6.5/dark/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch |
-| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape |
-| `google-play/phone/dark/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 |
-| `google-play/tablet-7/dark/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 |
-| `google-play/tablet-10/dark/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 |
-
-Each target captures thread, terminal, review, thread list, and environments. Each appearance
-folder's five screenshots satisfy the configured Apple limit of 1–10, Google
+| Output folder | Capture target | Upload dimensions | Store slot |
+| ------------------------------------- | ------------------------- | ----------------- | ----------------------------------------- |
+| `apple/iphone-6.9/dark/t3-code/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch |
+| `apple/iphone-6.5/dark/t3-code/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch |
+| `apple/ipad-13/dark/t3-code/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape |
+| `google-play/phone/dark/t3-code/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 |
+| `google-play/tablet-7/dark/t3-code/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 |
+| `google-play/tablet-10/dark/t3-code/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 |
+
+Each target captures thread, terminal, review, thread list, and environments. Each palette folder's
+five screenshots satisfy the configured Apple limit of 1–10, Google
phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8.
+Every palette gets its own leaf folder so one upload slot never mixes themes and each folder keeps a
+store-legal screenshot count.
The generated tree is deliberately aligned with the store upload fields:
artifacts/app-store/screenshots/
├── apple/
- │ ├── iphone-6.9/dark/{thread,terminal,review,threads,environments}.png
- │ ├── iphone-6.5/dark/{thread,terminal,review,threads,environments}.png
- │ └── ipad-13/dark/{thread,terminal,review,threads,environments}.png
+ │ ├── iphone-6.9/dark/t3-code/{thread,terminal,review,threads,environments}.png
+ │ ├── iphone-6.5/dark/t3-code/{thread,terminal,review,threads,environments}.png
+ │ └── ipad-13/dark/t3-code/{thread,terminal,review,threads,environments}.png
└── google-play/
- ├── phone/dark/{thread,terminal,review,threads,environments}.png
- ├── tablet-7/dark/{thread,terminal,review,threads,environments}.png
- └── tablet-10/dark/{thread,terminal,review,threads,environments}.png
+ ├── phone/dark/t3-code/{thread,terminal,review,threads,environments}.png
+ ├── tablet-7/dark/t3-code/{thread,terminal,review,threads,environments}.png
+ └── tablet-10/dark/t3-code/{thread,terminal,review,threads,environments}.png
A light-only run writes the same tree under `light/`; `--appearance both` writes both appearance
-folders.
+folders, and each requested theme adds a sibling folder next to `t3-code/`.
Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD
-names, light/dark appearance, iOS orientation, scenes, output directory, capture delay, Android ABI,
-or viewport.
+names, light/dark appearance, default palette, iOS orientation, scenes, output directory, capture
+delay, Android ABI, or viewport. The selectable palette ids come from `MOBILE_THEME_IDS` in
+[themePalettes.ts](../../packages/shared/src/themePalettes.ts), so the harness and the app's
+appearance settings can never drift apart.
## Capture in GitHub Actions
Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab, choose `all`, `ios`, or
-`android`, and select `light`, `dark`, or `both`. The default dispatch captures both appearances and
-runs iOS and Android concurrently: iPhone and iPad capture on a
+`android`, select `light`, `dark`, or `both`, and pick a palette (or `all`, which raises each job's
+timeout from 60 to 300 minutes). The default dispatch captures both appearances of the `t3-code`
+palette and runs iOS and Android concurrently: iPhone and iPad capture on a
12-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a
16-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator.
@@ -120,6 +131,12 @@ Override the configured appearance or capture both variants:
pnpm screenshots:mobile --appearance dark
pnpm screenshots:mobile --appearance both
+Capture other palettes:
+
+ pnpm screenshots:mobile --device iphone-6.9 --theme ocean
+ pnpm screenshots:mobile --device iphone-6.9 --theme ocean --theme ember
+ pnpm screenshots:mobile --device iphone-6.9 --theme all
+
Reuse the native build and retain the disposable environment:
pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running
diff --git a/docs/user/mobile-appearance.md b/docs/user/mobile-appearance.md
new file mode 100644
index 000000000000..f3ac966d859d
--- /dev/null
+++ b/docs/user/mobile-appearance.md
@@ -0,0 +1,15 @@
+# Mobile appearance
+
+T3 Code Mobile includes the T3 Code, T3 Chat, Grove, Ocean, Ember, and Iris themes. Each theme has
+light and dark colors that apply throughout the app, including code reviews, file previews, the
+terminal, native headers, and sheets.
+
+To change themes:
+
+1. Open **Settings**.
+2. Select **Appearance**.
+3. Choose a theme.
+4. Select **System**, **Light**, or **Dark**.
+
+**System** follows the device appearance automatically. Theme, text, code, and terminal appearance
+preferences are stored on the device.
diff --git a/packages/shared/package.json b/packages/shared/package.json
index f669bd0a452c..a797e97b6625 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -3,6 +3,14 @@
"private": true,
"type": "module",
"exports": {
+ "./themePalettes": {
+ "types": "./src/themePalettes.ts",
+ "import": "./src/themePalettes.ts"
+ },
+ "./themePreview": {
+ "types": "./src/themePreview.ts",
+ "import": "./src/themePreview.ts"
+ },
"./projectFavicon": {
"types": "./src/projectFavicon.ts",
"import": "./src/projectFavicon.ts"
diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts
new file mode 100644
index 000000000000..919a3bfaf3d0
--- /dev/null
+++ b/packages/shared/src/themePalettes.ts
@@ -0,0 +1,748 @@
+export const BUILT_IN_THEME_IDS = ["t3-chat", "grove", "ocean", "ember", "iris"] as const;
+
+/** The mobile app's own hand-tuned palette, which is not part of the built-in library. */
+export const MOBILE_DEFAULT_THEME_ID = "t3-code";
+
+/**
+ * Every palette the mobile app can render. Declared here so host-side tooling
+ * (the app-store screenshot harness) can validate a requested theme without
+ * importing React Native application code.
+ */
+export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const;
+
+export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number];
+export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number];
+export type ThemeAppearance = "light" | "dark";
+
+/** Product roles shared by web CSS, React Native tokens, and native surfaces. */
+export const THEME_COLOR_ROLES = [
+ "canvas",
+ "chrome",
+ "toolbar",
+ "toolbarForeground",
+ "toolbarBorder",
+ "toolbarControl",
+ "toolbarControlForeground",
+ "toolbarControlHover",
+ "surface",
+ "surfaceRaised",
+ "surfaceOverlay",
+ "text",
+ "textMuted",
+ "border",
+ "input",
+ "focus",
+ "accent",
+ "accentForeground",
+ "secondary",
+ "secondaryForeground",
+ "muted",
+ "mutedForeground",
+ "placeholder",
+ "secondaryLabel",
+ "iconMuted",
+ "error",
+ "errorForeground",
+ "errorSurface",
+ "warning",
+ "warningForeground",
+ "warningSurface",
+ "update",
+ "updateForeground",
+ "updateSurface",
+ "accentSurface",
+ "accentSurfaceForeground",
+ "messageSurface",
+ "messageForeground",
+ "messageAction",
+ "messageActionForeground",
+ "messageActionHover",
+ "codeBackground",
+ "codeForeground",
+ "sidebar",
+ "sidebarForeground",
+ "sidebarMutedForeground",
+ "sidebarControlSurface",
+ "sidebarRowHover",
+ "sidebarRowActive",
+ "sidebarRowSelected",
+ "sidebarBorder",
+ "terminalBackground",
+ "terminalForeground",
+ "terminalCursor",
+ "terminalSelection",
+ "terminalScrollbar",
+ "terminalScrollbarHover",
+] as const;
+
+export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number];
+export type ThemeColors = Readonly>;
+export type ThemeVariants = Readonly>>;
+export type ThemeDefinition = Readonly<{
+ id: string;
+ label: string;
+ appearance: ThemeAppearance;
+ colors: ThemeColors;
+ variants?: ThemeVariants;
+ /** Groups related imported variants into one library card. */
+ collection?: Readonly<{ id: string; label: string }>;
+ /** Allows reviewed built-ins to render product artwork over their sidebar. */
+ sidebarArtwork?: boolean;
+ /** Generated from the guided editor's canvas and accent roles. */
+ managed?: boolean;
+}>;
+
+export const T3_CHAT_THEME: ThemeDefinition = {
+ id: "t3-chat",
+ label: "T3 Chat",
+ appearance: "light",
+ colors: {
+ canvas: "oklch(0.982446 0.010114 325.653)",
+ chrome: "oklch(0.982446 0.010114 325.653)",
+ toolbar: "oklch(0.982446 0.010114 325.653)",
+ toolbarForeground: "oklch(0.325698 0.116116 325.037)",
+ toolbarBorder: "oklch(0.856784 0.082879 328.911)",
+ toolbarControl: "oklch(0.939552 0.024286 321.664)",
+ toolbarControlForeground: "oklch(0.325698 0.116116 325.037)",
+ toolbarControlHover: "oklch(0.884525 0.041658 337.177)",
+ surface: "oklch(0.971835 0.012884 321.894)",
+ surfaceRaised: "oklch(0.988235 0.005049 325.615)",
+ surfaceOverlay: "oklch(1 0 0)",
+ text: "oklch(0.325698 0.116116 325.037)",
+ textMuted: "oklch(0.494754 0.190937 354.544)",
+ border: "oklch(0.923531 0.021247 328.096)",
+ input: "oklch(0.851713 0.055822 336.6)",
+ focus: "oklch(0.591646 0.217985 0.584)",
+ accent: "oklch(0.591646 0.217985 0.584)",
+ accentForeground: "oklch(1 0 0)",
+ secondary: "oklch(0.869588 0.06751 334.899)",
+ secondaryForeground: "oklch(0.444777 0.134061 324.799)",
+ muted: "oklch(0.802407 0.090963 345.892)",
+ mutedForeground: "oklch(0.428932 0.163929 354.332)",
+ placeholder: "oklch(0.549927 0.090215 323.149)",
+ secondaryLabel: "oklch(0.494754 0.190937 354.544)",
+ iconMuted: "oklch(0.494754 0.190937 354.544)",
+ error: "oklch(0.627117 0.248974 7.734)",
+ errorForeground: "oklch(0.458704 0.169677 3.815)",
+ errorSurface: "oklch(0.942787 0.032076 344.963)",
+ warning: "oklch(0.76859 0.164659 70.08)",
+ warningForeground: "oklch(0.54612 0.143036 48.949)",
+ warningSurface: "oklch(0.962901 0.015297 48.56)",
+ update: "oklch(0.591646 0.217985 0.584)",
+ updateForeground: "oklch(0.494754 0.190937 354.544)",
+ updateSurface: "oklch(0.930264 0.036194 341.45)",
+ accentSurface: "oklch(0.939552 0.024286 321.664)",
+ accentSurfaceForeground: "oklch(0.396296 0.025134 285.196)",
+ messageSurface: "oklch(0.926746 0.037898 332.6)",
+ messageForeground: "oklch(0.354591 0.093575 307.568)",
+ messageAction: "oklch(0.591646 0.217985 0.584)",
+ messageActionForeground: "oklch(1 0 0)",
+ messageActionHover: "oklch(0.539042 0.197866 0.305)",
+ codeBackground: "oklch(0.953855 0.019695 315.668)",
+ codeForeground: "oklch(0.445128 0.13005 307.026)",
+ sidebar: "oklch(0.928886 0.031178 322.592)",
+ sidebarForeground: "oklch(0.396296 0.025134 285.196)",
+ sidebarMutedForeground: "oklch(0.494754 0.190937 354.544)",
+ sidebarControlSurface: "oklch(0.978851 0.001321 106.424)",
+ sidebarRowHover: "oklch(0.978851 0.001321 106.424)",
+ sidebarRowActive: "oklch(0.978851 0.001321 106.424)",
+ sidebarRowSelected: "oklch(0.978851 0.001321 106.424)",
+ sidebarBorder: "oklch(0.938313 0.002552 48.717)",
+ terminalBackground: "oklch(0.982446 0.010114 325.653)",
+ terminalForeground: "oklch(0.325698 0.116116 325.037)",
+ terminalCursor: "oklch(0.591646 0.217985 0.584)",
+ terminalSelection: "oklch(0.869588 0.06751 334.899)",
+ terminalScrollbar: "oklch(0.851713 0.055822 336.6)",
+ terminalScrollbarHover: "oklch(0.802407 0.090963 345.892)",
+ },
+ variants: {
+ dark: {
+ canvas: "oklch(0.22813 0.020366 307.469)",
+ chrome: "oklch(0.22813 0.020366 307.469)",
+ toolbar: "oklch(0.22813 0.020366 307.469)",
+ toolbarForeground: "oklch(0.980735 0.004092 301.426)",
+ toolbarBorder: "oklch(0.266943 0.015262 302.425)",
+ toolbarControl: "oklch(0.313674 0.030572 310.061)",
+ toolbarControlForeground: "oklch(0.848252 0.038248 307.961)",
+ toolbarControlHover: "oklch(0.364912 0.050794 308.491)",
+ surface: "oklch(0.267101 0.02016 311.799)",
+ surfaceRaised: "oklch(0.279864 0.021572 309.532)",
+ surfaceOverlay: "oklch(0.154761 0.01316 338.901)",
+ text: "oklch(0.980735 0.004092 301.426)",
+ textMuted: "oklch(0.880303 0.03077 342.696)",
+ border: "oklch(0.266943 0.015262 302.425)",
+ input: "oklch(0.266817 0.02897 344.461)",
+ focus: "oklch(0.591646 0.217985 0.584)",
+ accent: "oklch(0.460685 0.185347 4.099)",
+ accentForeground: "oklch(0.901233 0.057189 343.694)",
+ secondary: "oklch(0.313674 0.030572 310.061)",
+ secondaryForeground: "oklch(0.848252 0.038248 307.961)",
+ muted: "oklch(0.360924 0.021469 316.83)",
+ mutedForeground: "oklch(0.880303 0.03077 342.696)",
+ placeholder: "oklch(0.657087 0.028226 307.985)",
+ secondaryLabel: "oklch(0.880303 0.03077 342.696)",
+ iconMuted: "oklch(0.848252 0.038248 307.961)",
+ error: "oklch(0.458704 0.169677 3.815)",
+ errorForeground: "oklch(0.901233 0.057189 343.694)",
+ errorSurface: "oklch(0.259022 0.04799 340.062)",
+ warning: "oklch(0.76859 0.164659 70.08)",
+ warningForeground: "oklch(0.836861 0.164422 84.429)",
+ warningSurface: "oklch(0.321706 0.036256 60.806)",
+ update: "oklch(0.460685 0.185347 4.099)",
+ updateForeground: "oklch(0.901233 0.057189 343.694)",
+ updateSurface: "oklch(0.256077 0.063004 342.914)",
+ accentSurface: "oklch(0.364912 0.050794 308.491)",
+ accentSurfaceForeground: "oklch(0.964695 0.009139 341.803)",
+ messageSurface: "oklch(0.273791 0.025541 309.079)",
+ messageForeground: "oklch(0.949872 0.021269 306.838)",
+ messageAction: "oklch(0.460685 0.185347 4.099)",
+ messageActionForeground: "oklch(0.901233 0.057189 343.694)",
+ messageActionHover: "oklch(0.458754 0.184639 3.857)",
+ codeBackground: "oklch(0.22813 0.020366 307.469)",
+ codeForeground: "oklch(0.848703 0.064239 306.645)",
+ sidebar: "oklch(0.185778 0.019368 322.159)",
+ sidebarForeground: "oklch(0.967434 0.001326 286.375)",
+ sidebarMutedForeground: "oklch(0.880303 0.03077 342.696)",
+ sidebarControlSurface: "oklch(0.23366 0.026081 338.196)",
+ sidebarRowHover: "oklch(0.23366 0.026081 338.196)",
+ sidebarRowActive: "oklch(0.23366 0.026081 338.196)",
+ sidebarRowSelected: "oklch(0.23366 0.026081 338.196)",
+ sidebarBorder: "oklch(0.269132 0.030766 351.067)",
+ terminalBackground: "oklch(0.22813 0.020366 307.469)",
+ terminalForeground: "oklch(0.980735 0.004092 301.426)",
+ terminalCursor: "oklch(0.591646 0.217985 0.584)",
+ terminalSelection: "oklch(0.313674 0.030572 310.061)",
+ terminalScrollbar: "oklch(0.266817 0.02897 344.461)",
+ terminalScrollbarHover: "oklch(0.360924 0.021469 316.83)",
+ },
+ },
+ sidebarArtwork: true,
+};
+
+export const GROVE_THEME: ThemeDefinition = {
+ id: "grove",
+ label: "Grove",
+ appearance: "light",
+ colors: {
+ canvas: "oklch(0.972369 0.005497 157.15)",
+ chrome: "oklch(0.972369 0.005497 157.15)",
+ toolbar: "oklch(0.972369 0.005497 157.15)",
+ toolbarForeground: "oklch(0.222003 0.03479 328.979)",
+ toolbarBorder: "oklch(0.909438 0.021521 164.612)",
+ toolbarControl: "oklch(0.936464 0.014601 163.554)",
+ toolbarControlForeground: "oklch(0.222003 0.03479 328.979)",
+ toolbarControlHover: "oklch(0.909438 0.021521 164.612)",
+ surface: "oklch(0.972369 0.005497 157.15)",
+ surfaceRaised: "oklch(0.949276 0.004496 159.002)",
+ surfaceOverlay: "oklch(0.932695 0.003778 160.944)",
+ text: "oklch(0.222003 0.03479 328.979)",
+ textMuted: "oklch(0.540472 0.014944 326.176)",
+ border: "oklch(0.864831 0.01312 167.255)",
+ input: "oklch(0.829746 0.016084 168.234)",
+ focus: "oklch(0.523295 0.112292 158.089)",
+ accent: "oklch(0.523295 0.112292 158.089)",
+ accentForeground: "oklch(0.990339 0.008411 325.64)",
+ secondary: "oklch(0.936464 0.014601 163.554)",
+ secondaryForeground: "oklch(0.222003 0.03479 328.979)",
+ muted: "oklch(0.945455 0.012308 162.879)",
+ mutedForeground: "oklch(0.527266 0.012309 320.683)",
+ placeholder: "oklch(0.529681 0.01551 326.299)",
+ secondaryLabel: "oklch(0.540472 0.014944 326.176)",
+ iconMuted: "oklch(0.540472 0.014944 326.176)",
+ error: "oklch(0.637823 0.237287 25.436)",
+ errorForeground: "oklch(0.509494 0.208583 28.513)",
+ errorSurface: "oklch(0.936968 0.014243 26.295)",
+ warning: "oklch(0.772406 0.172798 65.367)",
+ warningForeground: "oklch(0.545036 0.155019 45.359)",
+ warningSurface: "oklch(0.953175 0.02009 93.379)",
+ update: "oklch(0.523295 0.112292 158.089)",
+ updateForeground: "oklch(0.388012 0.080082 158.768)",
+ updateSurface: "oklch(0.900411 0.02384 164.795)",
+ accentSurface: "oklch(0.909438 0.021521 164.612)",
+ accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)",
+ messageSurface: "oklch(0.891377 0.026164 164.929)",
+ messageForeground: "oklch(0.222003 0.03479 328.979)",
+ messageAction: "oklch(0.535028 0.106403 77.549)",
+ messageActionForeground: "oklch(0.990339 0.008411 325.64)",
+ messageActionHover: "oklch(0.488753 0.096536 77.829)",
+ codeBackground: "oklch(0.955888 0.004783 158.391)",
+ codeForeground: "oklch(0.222003 0.03479 328.979)",
+ sidebar: "oklch(0.936464 0.014601 163.554)",
+ sidebarForeground: "oklch(0.222003 0.03479 328.979)",
+ sidebarMutedForeground: "oklch(0.515606 0.011938 318.897)",
+ sidebarControlSurface: "oklch(0.88585 0.011734 166.331)",
+ sidebarRowHover: "oklch(0.886676 0.027374 164.983)",
+ sidebarRowActive: "oklch(0.85335 0.03597 165.158)",
+ sidebarRowSelected: "oklch(0.836654 0.040284 165.149)",
+ sidebarBorder: "oklch(0.860274 0.010287 168.339)",
+ terminalBackground: "oklch(0.972369 0.005497 157.15)",
+ terminalForeground: "oklch(0.222003 0.03479 328.979)",
+ terminalCursor: "oklch(0.523295 0.112292 158.089)",
+ terminalSelection: "oklch(0.891377 0.026164 164.929)",
+ terminalScrollbar: "oklch(0.824752 0.001392 294.641)",
+ terminalScrollbarHover: "oklch(0.755495 0.004415 318.776)",
+ },
+ variants: {
+ dark: {
+ canvas: "oklch(0.260865 0.02152 162.75)",
+ chrome: "oklch(0.260865 0.02152 162.75)",
+ toolbar: "oklch(0.260865 0.02152 162.75)",
+ toolbarForeground: "oklch(0.990339 0.008411 325.64)",
+ toolbarBorder: "oklch(0.464636 0.066083 158.72)",
+ toolbarControl: "oklch(0.380487 0.048313 159.608)",
+ toolbarControlForeground: "oklch(0.990339 0.008411 325.64)",
+ toolbarControlHover: "oklch(0.437021 0.060312 158.962)",
+ surface: "oklch(0.260865 0.02152 162.75)",
+ surfaceRaised: "oklch(0.363192 0.016572 165.32)",
+ surfaceOverlay: "oklch(0.411828 0.014378 166.627)",
+ text: "oklch(0.990339 0.008411 325.64)",
+ textMuted: "oklch(0.666747 0.004239 187.292)",
+ border: "oklch(0.457475 0.044046 160.971)",
+ input: "oklch(0.519849 0.049896 160.863)",
+ focus: "oklch(0.796228 0.133058 157.319)",
+ accent: "oklch(0.796228 0.133058 157.319)",
+ accentForeground: "oklch(0.222003 0.03479 328.979)",
+ secondary: "oklch(0.380487 0.048313 159.608)",
+ secondaryForeground: "oklch(0.990339 0.008411 325.64)",
+ muted: "oklch(0.339728 0.039456 160.274)",
+ mutedForeground: "oklch(0.715427 0.010896 171.428)",
+ placeholder: "oklch(0.739243 0.002222 223.225)",
+ secondaryLabel: "oklch(0.666747 0.004239 187.292)",
+ iconMuted: "oklch(0.666747 0.004239 187.292)",
+ error: "oklch(0.655108 0.221148 23.473)",
+ errorForeground: "oklch(0.704237 0.187511 22.228)",
+ errorSurface: "oklch(0.312773 0.02923 32.121)",
+ warning: "oklch(0.772406 0.172798 65.367)",
+ warningForeground: "oklch(0.829017 0.171221 81.038)",
+ warningSurface: "oklch(0.345524 0.046882 99.736)",
+ update: "oklch(0.796228 0.133058 157.319)",
+ updateForeground: "oklch(0.86276 0.089288 159.704)",
+ updateSurface: "oklch(0.448116 0.062637 158.86)",
+ accentSurface: "oklch(0.437021 0.060312 158.962)",
+ accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)",
+ messageSurface: "oklch(0.470111 0.067221 158.676)",
+ messageForeground: "oklch(0.990339 0.008411 325.64)",
+ messageAction: "oklch(0.791603 0.129713 83.299)",
+ messageActionForeground: "oklch(0.222003 0.03479 328.979)",
+ messageActionHover: "oklch(0.815227 0.117902 84.21)",
+ codeBackground: "oklch(0.312979 0.018942 164.082)",
+ codeForeground: "oklch(0.990339 0.008411 325.64)",
+ sidebar: "oklch(0.309925 0.032827 160.944)",
+ sidebarForeground: "oklch(0.990339 0.008411 325.64)",
+ sidebarMutedForeground: "oklch(0.711387 0.007643 175.89)",
+ sidebarControlSurface: "oklch(0.432727 0.024549 163.654)",
+ sidebarRowHover: "oklch(0.374959 0.047124 159.686)",
+ sidebarRowActive: "oklch(0.41688 0.056069 159.165)",
+ sidebarRowSelected: "oklch(0.437466 0.060406 158.958)",
+ sidebarBorder: "oklch(0.569253 0.015933 167.062)",
+ terminalBackground: "oklch(0.260865 0.02152 162.75)",
+ terminalForeground: "oklch(0.990339 0.008411 325.64)",
+ terminalCursor: "oklch(0.796228 0.133058 157.319)",
+ terminalSelection: "oklch(0.464636 0.066083 158.72)",
+ terminalScrollbar: "oklch(0.594692 0.006862 176.022)",
+ terminalScrollbarHover: "oklch(0.687968 0.00354 193.55)",
+ },
+ },
+ sidebarArtwork: true,
+};
+
+export const OCEAN_THEME: ThemeDefinition = {
+ id: "ocean",
+ label: "Ocean",
+ appearance: "light",
+ colors: {
+ canvas: "oklch(0.974199 0.002856 241.597)",
+ chrome: "oklch(0.974199 0.002856 241.597)",
+ toolbar: "oklch(0.974199 0.002856 241.597)",
+ toolbarForeground: "oklch(0.222003 0.03479 328.979)",
+ toolbarBorder: "oklch(0.91295 0.018827 241.836)",
+ toolbarControl: "oklch(0.939254 0.01193 241.729)",
+ toolbarControlForeground: "oklch(0.222003 0.03479 328.979)",
+ toolbarControlHover: "oklch(0.91295 0.018827 241.836)",
+ surface: "oklch(0.974199 0.002856 241.597)",
+ surfaceRaised: "oklch(0.951058 0.002962 258.339)",
+ surfaceOverlay: "oklch(0.934442 0.003181 269.1)",
+ text: "oklch(0.222003 0.03479 328.979)",
+ textMuted: "oklch(0.541555 0.017468 323.531)",
+ border: "oklch(0.867646 0.013482 252.362)",
+ input: "oklch(0.832939 0.017389 252.598)",
+ focus: "oklch(0.536684 0.120219 247.01)",
+ accent: "oklch(0.536684 0.120219 247.01)",
+ accentForeground: "oklch(0.990339 0.008411 325.64)",
+ secondary: "oklch(0.939254 0.01193 241.729)",
+ secondaryForeground: "oklch(0.222003 0.03479 328.979)",
+ muted: "oklch(0.948004 0.009649 241.695)",
+ mutedForeground: "oklch(0.528741 0.01828 313.823)",
+ placeholder: "oklch(0.530733 0.01795 323.79)",
+ secondaryLabel: "oklch(0.541555 0.017468 323.531)",
+ iconMuted: "oklch(0.541555 0.017468 323.531)",
+ error: "oklch(0.637823 0.237287 25.436)",
+ errorForeground: "oklch(0.509494 0.208583 28.513)",
+ errorSurface: "oklch(0.938747 0.016377 7.186)",
+ warning: "oklch(0.772406 0.172798 65.367)",
+ warningForeground: "oklch(0.546927 0.155556 45.359)",
+ warningSurface: "oklch(0.954846 0.016009 81.731)",
+ update: "oklch(0.536684 0.120219 247.01)",
+ updateForeground: "oklch(0.397497 0.084999 246.523)",
+ updateSurface: "oklch(0.904165 0.021144 241.874)",
+ accentSurface: "oklch(0.91295 0.018827 241.836)",
+ accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)",
+ messageSurface: "oklch(0.895373 0.023469 241.913)",
+ messageForeground: "oklch(0.222003 0.03479 328.979)",
+ messageAction: "oklch(0.493961 0.08175 201.584)",
+ messageActionForeground: "oklch(0.990339 0.008411 325.64)",
+ messageActionHover: "oklch(0.45151 0.074407 201.516)",
+ codeBackground: "oklch(0.957684 0.002906 253.68)",
+ codeForeground: "oklch(0.222003 0.03479 328.979)",
+ sidebar: "oklch(0.939254 0.01193 241.729)",
+ sidebarForeground: "oklch(0.222003 0.03479 328.979)",
+ sidebarMutedForeground: "oklch(0.517366 0.018944 311.433)",
+ sidebarControlSurface: "oklch(0.888479 0.011475 251.638)",
+ sidebarRowHover: "oklch(0.890798 0.024681 241.933)",
+ sidebarRowActive: "oklch(0.858363 0.033325 242.089)",
+ sidebarRowSelected: "oklch(0.842113 0.037689 242.174)",
+ sidebarBorder: "oklch(0.862823 0.011384 256.926)",
+ terminalBackground: "oklch(0.974199 0.002856 241.597)",
+ terminalForeground: "oklch(0.222003 0.03479 328.979)",
+ terminalCursor: "oklch(0.536684 0.120219 247.01)",
+ terminalSelection: "oklch(0.895373 0.023469 241.913)",
+ terminalScrollbar: "oklch(0.826271 0.006191 305.456)",
+ terminalScrollbarHover: "oklch(0.756866 0.008685 313.721)",
+ },
+ variants: {
+ dark: {
+ canvas: "oklch(0.242641 0.024125 250.573)",
+ chrome: "oklch(0.242641 0.024125 250.573)",
+ toolbar: "oklch(0.242641 0.024125 250.573)",
+ toolbarForeground: "oklch(0.990339 0.008411 325.64)",
+ toolbarBorder: "oklch(0.439946 0.0561 243.479)",
+ toolbarControl: "oklch(0.358725 0.043145 244.911)",
+ toolbarControlForeground: "oklch(0.990339 0.008411 325.64)",
+ toolbarControlHover: "oklch(0.413315 0.051874 243.855)",
+ surface: "oklch(0.242641 0.024125 250.573)",
+ surfaceRaised: "oklch(0.348439 0.019942 253.696)",
+ surfaceOverlay: "oklch(0.398517 0.018232 255.72)",
+ text: "oklch(0.990339 0.008411 325.64)",
+ textMuted: "oklch(0.652227 0.01149 273.31)",
+ border: "oklch(0.438653 0.039496 245.44)",
+ input: "oklch(0.500905 0.043574 244.781)",
+ focus: "oklch(0.758933 0.105833 241.548)",
+ accent: "oklch(0.758933 0.105833 241.548)",
+ accentForeground: "oklch(0.222003 0.03479 328.979)",
+ secondary: "oklch(0.358725 0.043145 244.911)",
+ secondaryForeground: "oklch(0.990339 0.008411 325.64)",
+ muted: "oklch(0.319287 0.036766 246.065)",
+ mutedForeground: "oklch(0.691936 0.016294 261.588)",
+ placeholder: "oklch(0.721641 0.010192 281.271)",
+ secondaryLabel: "oklch(0.652227 0.01149 273.31)",
+ iconMuted: "oklch(0.652227 0.01149 273.31)",
+ error: "oklch(0.655108 0.221148 23.473)",
+ errorForeground: "oklch(0.702184 0.189226 22.228)",
+ errorSurface: "oklch(0.298933 0.036443 350.094)",
+ warning: "oklch(0.772406 0.172798 65.367)",
+ warningForeground: "oklch(0.829017 0.171221 81.038)",
+ warningSurface: "oklch(0.329449 0.028712 84.495)",
+ update: "oklch(0.758933 0.105833 241.548)",
+ updateForeground: "oklch(0.840844 0.069217 240.151)",
+ updateSurface: "oklch(0.424017 0.053575 243.695)",
+ accentSurface: "oklch(0.413315 0.051874 243.855)",
+ accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)",
+ messageSurface: "oklch(0.445224 0.056936 243.413)",
+ messageForeground: "oklch(0.990339 0.008411 325.64)",
+ messageAction: "oklch(0.793363 0.105022 199.893)",
+ messageActionForeground: "oklch(0.222003 0.03479 328.979)",
+ messageActionHover: "oklch(0.815308 0.096174 199.862)",
+ codeBackground: "oklch(0.29661 0.021883 251.968)",
+ codeForeground: "oklch(0.990339 0.008411 325.64)",
+ sidebar: "oklch(0.290387 0.032043 247.274)",
+ sidebarForeground: "oklch(0.990339 0.008411 325.64)",
+ sidebarMutedForeground: "oklch(0.69099 0.01395 266.424)",
+ sidebarControlSurface: "oklch(0.417822 0.02535 250.162)",
+ sidebarRowHover: "oklch(0.353381 0.042285 245.043)",
+ sidebarRowActive: "oklch(0.393878 0.048778 244.179)",
+ sidebarRowSelected: "oklch(0.413744 0.051943 243.848)",
+ sidebarBorder: "oklch(0.55859 0.019001 256.223)",
+ terminalBackground: "oklch(0.242641 0.024125 250.573)",
+ terminalForeground: "oklch(0.990339 0.008411 325.64)",
+ terminalCursor: "oklch(0.758933 0.105833 241.548)",
+ terminalSelection: "oklch(0.439946 0.0561 243.479)",
+ terminalScrollbar: "oklch(0.58613 0.012959 267.22)",
+ terminalScrollbarHover: "oklch(0.681569 0.010909 276.465)",
+ },
+ },
+ sidebarArtwork: true,
+};
+
+export const EMBER_THEME: ThemeDefinition = {
+ id: "ember",
+ label: "Ember",
+ appearance: "light",
+ colors: {
+ canvas: "oklch(0.976527 0.002685 60.725)",
+ chrome: "oklch(0.976527 0.002685 60.725)",
+ toolbar: "oklch(0.976527 0.002685 60.725)",
+ toolbarForeground: "oklch(0.222003 0.03479 328.979)",
+ toolbarBorder: "oklch(0.916502 0.01832 49.597)",
+ toolbarControl: "oklch(0.942267 0.01151 50.785)",
+ toolbarControlForeground: "oklch(0.222003 0.03479 328.979)",
+ toolbarControlHover: "oklch(0.916502 0.01832 49.597)",
+ surface: "oklch(0.976527 0.002685 60.725)",
+ surfaceRaised: "oklch(0.953321 0.002701 42.266)",
+ surfaceOverlay: "oklch(0.936659 0.002879 29.96)",
+ text: "oklch(0.222003 0.03479 328.979)",
+ textMuted: "oklch(0.543023 0.017316 331.964)",
+ border: "oklch(0.870631 0.013204 39.431)",
+ input: "oklch(0.836213 0.017153 38.661)",
+ focus: "oklch(0.552831 0.129438 44.656)",
+ accent: "oklch(0.552831 0.129438 44.656)",
+ accentForeground: "oklch(0.990339 0.008411 325.64)",
+ secondary: "oklch(0.942267 0.01151 50.785)",
+ secondaryForeground: "oklch(0.222003 0.03479 328.979)",
+ muted: "oklch(0.950842 0.009273 51.528)",
+ mutedForeground: "oklch(0.530413 0.018453 341.181)",
+ placeholder: "oklch(0.532339 0.017796 331.748)",
+ secondaryLabel: "oklch(0.543023 0.017316 331.964)",
+ iconMuted: "oklch(0.543023 0.017316 331.964)",
+ error: "oklch(0.637823 0.237287 25.436)",
+ errorForeground: "oklch(0.509494 0.208583 28.513)",
+ errorSurface: "oklch(0.941094 0.019938 19.375)",
+ warning: "oklch(0.772406 0.172798 65.367)",
+ warningForeground: "oklch(0.549154 0.156188 45.359)",
+ warningSurface: "oklch(0.957148 0.020843 76.702)",
+ update: "oklch(0.552831 0.129438 44.656)",
+ updateForeground: "oklch(0.408647 0.091207 45.037)",
+ updateSurface: "oklch(0.907902 0.020621 49.36)",
+ accentSurface: "oklch(0.916502 0.01832 49.597)",
+ accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)",
+ messageSurface: "oklch(0.899296 0.022939 49.163)",
+ messageForeground: "oklch(0.222003 0.03479 328.979)",
+ messageAction: "oklch(0.516323 0.161628 24.82)",
+ messageActionForeground: "oklch(0.990339 0.008411 325.64)",
+ messageActionHover: "oklch(0.471223 0.145843 24.688)",
+ codeBackground: "oklch(0.959965 0.002668 47.512)",
+ codeForeground: "oklch(0.222003 0.03479 328.979)",
+ sidebar: "oklch(0.942267 0.01151 50.785)",
+ sidebarForeground: "oklch(0.222003 0.03479 328.979)",
+ sidebarMutedForeground: "oklch(0.519146 0.019214 343.427)",
+ sidebarControlSurface: "oklch(0.891332 0.011179 40.596)",
+ sidebarRowHover: "oklch(0.894819 0.024151 49.073)",
+ sidebarRowActive: "oklch(0.863104 0.032855 48.586)",
+ sidebarRowSelected: "oklch(0.84723 0.037292 48.403)",
+ sidebarBorder: "oklch(0.865593 0.011154 35.246)",
+ terminalBackground: "oklch(0.976527 0.002685 60.725)",
+ terminalForeground: "oklch(0.222003 0.03479 328.979)",
+ terminalCursor: "oklch(0.552831 0.129438 44.656)",
+ terminalSelection: "oklch(0.899296 0.022939 49.163)",
+ terminalScrollbar: "oklch(0.828185 0.005884 349.533)",
+ terminalScrollbarHover: "oklch(0.758584 0.008423 341.16)",
+ },
+ variants: {
+ dark: {
+ canvas: "oklch(0.245899 0.019144 42.044)",
+ chrome: "oklch(0.245899 0.019144 42.044)",
+ toolbar: "oklch(0.245899 0.019144 42.044)",
+ toolbarForeground: "oklch(0.990339 0.008411 325.64)",
+ toolbarBorder: "oklch(0.442681 0.0608 50.795)",
+ toolbarControl: "oklch(0.361499 0.044052 49.515)",
+ toolbarControlForeground: "oklch(0.990339 0.008411 325.64)",
+ toolbarControlHover: "oklch(0.416048 0.055354 50.484)",
+ surface: "oklch(0.245899 0.019144 42.044)",
+ surfaceRaised: "oklch(0.351262 0.01565 37.592)",
+ surfaceOverlay: "oklch(0.401111 0.014308 34.896)",
+ text: "oklch(0.990339 0.008411 325.64)",
+ textMuted: "oklch(0.654017 0.009505 13.287)",
+ border: "oklch(0.44099 0.040202 48.807)",
+ input: "oklch(0.503003 0.045721 49.44)",
+ focus: "oklch(0.762174 0.124117 52.082)",
+ accent: "oklch(0.762174 0.124117 52.082)",
+ accentForeground: "oklch(0.222003 0.03479 328.979)",
+ secondary: "oklch(0.361499 0.044052 49.515)",
+ secondaryForeground: "oklch(0.990339 0.008411 325.64)",
+ muted: "oklch(0.322144 0.03574 48.309)",
+ mutedForeground: "oklch(0.692479 0.015227 30.963)",
+ placeholder: "oklch(0.723533 0.008741 4.515)",
+ secondaryLabel: "oklch(0.654017 0.009505 13.287)",
+ iconMuted: "oklch(0.654017 0.009505 13.287)",
+ error: "oklch(0.655108 0.221148 23.473)",
+ errorForeground: "oklch(0.702184 0.189226 22.228)",
+ errorSurface: "oklch(0.310955 0.059624 24.334)",
+ warning: "oklch(0.772406 0.172798 65.367)",
+ warningForeground: "oklch(0.829017 0.171221 81.038)",
+ warningSurface: "oklch(0.339137 0.055638 66.911)",
+ update: "oklch(0.762174 0.124117 52.082)",
+ updateForeground: "oklch(0.841456 0.079585 53.521)",
+ updateSurface: "oklch(0.426749 0.057547 50.618)",
+ accentSurface: "oklch(0.416048 0.055354 50.484)",
+ accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)",
+ messageSurface: "oklch(0.447961 0.061874 50.849)",
+ messageForeground: "oklch(0.990339 0.008411 325.64)",
+ messageAction: "oklch(0.747955 0.135578 29.432)",
+ messageActionForeground: "oklch(0.222003 0.03479 328.979)",
+ messageActionHover: "oklch(0.775116 0.117953 29.014)",
+ codeBackground: "oklch(0.299662 0.017229 39.973)",
+ codeForeground: "oklch(0.990339 0.008411 325.64)",
+ sidebar: "oklch(0.293349 0.029554 46.882)",
+ sidebarForeground: "oklch(0.990339 0.008411 325.64)",
+ sidebarMutedForeground: "oklch(0.691874 0.012538 24.638)",
+ sidebarControlSurface: "oklch(0.420227 0.022893 43.226)",
+ sidebarRowHover: "oklch(0.356163 0.042933 49.385)",
+ sidebarRowActive: "oklch(0.396617 0.051353 50.201)",
+ sidebarRowSelected: "oklch(0.416477 0.055442 50.489)",
+ sidebarBorder: "oklch(0.560372 0.016998 36.179)",
+ terminalBackground: "oklch(0.245899 0.019144 42.044)",
+ terminalForeground: "oklch(0.990339 0.008411 325.64)",
+ terminalCursor: "oklch(0.762174 0.124117 52.082)",
+ terminalSelection: "oklch(0.442681 0.0608 50.795)",
+ terminalScrollbar: "oklch(0.587861 0.010463 20.444)",
+ terminalScrollbarHover: "oklch(0.682876 0.009156 9.796)",
+ },
+ },
+ sidebarArtwork: true,
+};
+
+export const IRIS_THEME: ThemeDefinition = {
+ id: "iris",
+ label: "Iris",
+ appearance: "light",
+ colors: {
+ canvas: "oklch(0.976531 0.003855 303.226)",
+ chrome: "oklch(0.976531 0.003855 303.226)",
+ toolbar: "oklch(0.976531 0.003855 303.226)",
+ toolbarForeground: "oklch(0.222003 0.03479 328.979)",
+ toolbarBorder: "oklch(0.914882 0.022965 299.986)",
+ toolbarControl: "oklch(0.941387 0.014687 300.474)",
+ toolbarControlForeground: "oklch(0.222003 0.03479 328.979)",
+ toolbarControlHover: "oklch(0.914882 0.022965 299.986)",
+ surface: "oklch(0.976531 0.003855 303.226)",
+ surfaceRaised: "oklch(0.953326 0.004536 307.676)",
+ surfaceOverlay: "oklch(0.936665 0.005041 310.132)",
+ text: "oklch(0.222003 0.03479 328.979)",
+ textMuted: "oklch(0.543042 0.018894 325.652)",
+ border: "oklch(0.869608 0.018226 303.859)",
+ input: "oklch(0.834773 0.023405 303.676)",
+ focus: "oklch(0.525348 0.15373 294.176)",
+ accent: "oklch(0.525348 0.15373 294.176)",
+ accentForeground: "oklch(0.990339 0.008411 325.64)",
+ secondary: "oklch(0.941387 0.014687 300.474)",
+ secondaryForeground: "oklch(0.222003 0.03479 328.979)",
+ muted: "oklch(0.950194 0.011956 300.733)",
+ mutedForeground: "oklch(0.529955 0.022319 321.556)",
+ placeholder: "oklch(0.532177 0.019333 325.784)",
+ secondaryLabel: "oklch(0.543042 0.018894 325.652)",
+ iconMuted: "oklch(0.543042 0.018894 325.652)",
+ error: "oklch(0.637823 0.237287 25.436)",
+ errorForeground: "oklch(0.509494 0.208583 28.513)",
+ errorSurface: "oklch(0.941043 0.019582 4.235)",
+ warning: "oklch(0.772406 0.172798 65.367)",
+ warningForeground: "oklch(0.549154 0.156188 45.359)",
+ warningSurface: "oklch(0.957054 0.016197 69.932)",
+ update: "oklch(0.525348 0.15373 294.176)",
+ updateForeground: "oklch(0.389926 0.10825 294.547)",
+ updateSurface: "oklch(0.90602 0.025754 299.867)",
+ accentSurface: "oklch(0.914882 0.022965 299.986)",
+ accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)",
+ messageSurface: "oklch(0.897143 0.028558 299.758)",
+ messageForeground: "oklch(0.222003 0.03479 328.979)",
+ messageAction: "oklch(0.516084 0.185229 340.776)",
+ messageActionForeground: "oklch(0.990339 0.008411 325.64)",
+ messageActionHover: "oklch(0.471003 0.16748 340.687)",
+ codeBackground: "oklch(0.95997 0.004338 306.542)",
+ codeForeground: "oklch(0.222003 0.03479 328.979)",
+ sidebar: "oklch(0.941387 0.014687 300.474)",
+ sidebarForeground: "oklch(0.222003 0.03479 328.979)",
+ sidebarMutedForeground: "oklch(0.518417 0.023683 320.681)",
+ sidebarControlSurface: "oklch(0.890512 0.0155 303.803)",
+ sidebarRowHover: "oklch(0.892522 0.030022 299.704)",
+ sidebarRowActive: "oklch(0.85971 0.040501 299.36)",
+ sidebarRowSelected: "oklch(0.843236 0.045818 299.198)",
+ sidebarBorder: "oklch(0.864805 0.015938 305.371)",
+ terminalBackground: "oklch(0.976531 0.003855 303.226)",
+ terminalForeground: "oklch(0.222003 0.03479 328.979)",
+ terminalCursor: "oklch(0.525348 0.15373 294.176)",
+ terminalSelection: "oklch(0.897143 0.028558 299.758)",
+ terminalScrollbar: "oklch(0.828195 0.008526 318.858)",
+ terminalScrollbarHover: "oklch(0.758596 0.010892 321.538)",
+ },
+ variants: {
+ dark: {
+ canvas: "oklch(0.225975 0.031062 293.741)",
+ chrome: "oklch(0.225975 0.031062 293.741)",
+ toolbar: "oklch(0.225975 0.031062 293.741)",
+ toolbarForeground: "oklch(0.990339 0.008411 325.64)",
+ toolbarBorder: "oklch(0.395417 0.085554 294.182)",
+ toolbarControl: "oklch(0.325405 0.063614 294.23)",
+ toolbarControlForeground: "oklch(0.990339 0.008411 325.64)",
+ toolbarControlHover: "oklch(0.372436 0.07841 294.204)",
+ surface: "oklch(0.225975 0.031062 293.741)",
+ surfaceRaised: "oklch(0.335291 0.026008 296.394)",
+ surfaceOverlay: "oklch(0.386739 0.024023 297.509)",
+ text: "oklch(0.990339 0.008411 325.64)",
+ textMuted: "oklch(0.640465 0.016197 304.171)",
+ border: "oklch(0.40874 0.058536 295.893)",
+ input: "oklch(0.46756 0.065775 296.265)",
+ focus: "oklch(0.671712 0.169136 293.929)",
+ accent: "oklch(0.671712 0.169136 293.929)",
+ accentForeground: "oklch(0.222003 0.03479 328.979)",
+ secondary: "oklch(0.325405 0.063614 294.23)",
+ secondaryForeground: "oklch(0.990339 0.008411 325.64)",
+ muted: "oklch(0.291515 0.05276 294.209)",
+ mutedForeground: "oklch(0.663321 0.025932 301.862)",
+ placeholder: "oklch(0.706249 0.014508 306.607)",
+ secondaryLabel: "oklch(0.640465 0.016197 304.171)",
+ iconMuted: "oklch(0.640465 0.016197 304.171)",
+ error: "oklch(0.655108 0.221148 23.473)",
+ errorForeground: "oklch(0.702184 0.189226 22.228)",
+ errorSurface: "oklch(0.291658 0.054707 352.238)",
+ warning: "oklch(0.772406 0.172798 65.367)",
+ warningForeground: "oklch(0.829017 0.171221 81.038)",
+ warningSurface: "oklch(0.318952 0.033845 51.646)",
+ update: "oklch(0.671712 0.169136 293.929)",
+ updateForeground: "oklch(0.785032 0.108439 296.344)",
+ updateSurface: "oklch(0.381668 0.081286 294.195)",
+ accentSurface: "oklch(0.372436 0.07841 294.204)",
+ accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)",
+ messageSurface: "oklch(0.399975 0.086965 294.177)",
+ messageForeground: "oklch(0.990339 0.008411 325.64)",
+ messageAction: "oklch(0.789904 0.130063 337.621)",
+ messageActionForeground: "oklch(0.222003 0.03479 328.979)",
+ messageActionHover: "oklch(0.813537 0.114101 337.23)",
+ codeBackground: "oklch(0.281873 0.028308 295.193)",
+ codeForeground: "oklch(0.990339 0.008411 325.64)",
+ sidebar: "oklch(0.266743 0.044689 294.138)",
+ sidebarForeground: "oklch(0.990339 0.008411 325.64)",
+ sidebarMutedForeground: "oklch(0.668773 0.021522 302.949)",
+ sidebarControlSurface: "oklch(0.399977 0.035678 297.031)",
+ sidebarRowHover: "oklch(0.320808 0.062152 294.23)",
+ sidebarRowActive: "oklch(0.355677 0.073167 294.217)",
+ sidebarRowSelected: "oklch(0.372806 0.078525 294.203)",
+ sidebarBorder: "oklch(0.545895 0.027522 299.871)",
+ terminalBackground: "oklch(0.225975 0.031062 293.741)",
+ terminalForeground: "oklch(0.990339 0.008411 325.64)",
+ terminalCursor: "oklch(0.671712 0.169136 293.929)",
+ terminalSelection: "oklch(0.395417 0.085554 294.182)",
+ terminalScrollbar: "oklch(0.578663 0.017888 302.229)",
+ terminalScrollbarHover: "oklch(0.676012 0.015271 305.433)",
+ },
+ },
+ sidebarArtwork: true,
+};
+
+export const BUILT_IN_THEMES: ReadonlyArray = [
+ T3_CHAT_THEME,
+ GROVE_THEME,
+ OCEAN_THEME,
+ EMBER_THEME,
+ IRIS_THEME,
+];
+
+export function getBuiltInTheme(id: string): ThemeDefinition | null {
+ return BUILT_IN_THEMES.find((theme) => theme.id === id) ?? null;
+}
+
+export function getThemeColorsForAppearance(
+ theme: ThemeDefinition,
+ appearance: ThemeAppearance,
+): ThemeColors | null {
+ if (theme.appearance === appearance) return theme.colors;
+ return theme.variants?.[appearance] ?? null;
+}
diff --git a/packages/shared/src/themePreview.test.ts b/packages/shared/src/themePreview.test.ts
new file mode 100644
index 000000000000..f1cc02e10f7a
--- /dev/null
+++ b/packages/shared/src/themePreview.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import {
+ mixThemePreviewBase,
+ STANDARD_THEME_PREVIEW_COLORS,
+ THEME_PREVIEW_RENDER_SPECS,
+} from "./themePreview.js";
+
+describe("theme preview", () => {
+ it("keeps the desktop preview geometry stable across clients", () => {
+ expect(THEME_PREVIEW_RENDER_SPECS.light!.accent.center).toEqual([0.72, 0.22]);
+ expect(THEME_PREVIEW_RENDER_SPECS.dark!.accent.middleOpacity).toBe(0.62);
+ expect(THEME_PREVIEW_RENDER_SPECS.dark!.action.center).toEqual([0.82, 0.18]);
+ });
+
+ it("mixes the standard canvas bases in OKLab", () => {
+ expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.light!, "light")).toBe("#fdfdfd");
+ expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.dark!, "dark")).toBe("#0a0a0a");
+ });
+});
diff --git a/packages/shared/src/themePreview.ts b/packages/shared/src/themePreview.ts
new file mode 100644
index 000000000000..62ac593d4c28
--- /dev/null
+++ b/packages/shared/src/themePreview.ts
@@ -0,0 +1,145 @@
+import type { ThemeAppearance } from "./themePalettes.js";
+
+export type ThemePreviewColors = Readonly<{
+ canvas: string;
+ accent: string;
+ messageAction: string;
+}>;
+
+/** The standard T3 Code artwork is not a built-in theme, so its preview colors live here. */
+export const STANDARD_THEME_PREVIEW_COLORS: Readonly> =
+ {
+ light: {
+ canvas: "#fcfcfc",
+ accent: "#f4f4f5",
+ messageAction: "#4f46e5",
+ },
+ dark: {
+ canvas: "#0a0a0a",
+ accent: "#1c1c1f",
+ messageAction: "#8b9cff",
+ },
+ };
+
+export type ThemePreviewRenderSpec = Readonly<{
+ baseTarget: string;
+ baseWeight: number;
+ accent: Readonly<{
+ center: readonly [x: number, y: number];
+ middleOffset: number;
+ middleOpacity: number;
+ endOffset: number;
+ }>;
+ action: Readonly<{
+ center: readonly [x: number, y: number];
+ startOpacity: number;
+ endOffset: number;
+ }>;
+ scale: number;
+ blurAt56Px: number;
+}>;
+
+/** Shared geometry and falloff for the web and native theme preview orbs. */
+export const THEME_PREVIEW_RENDER_SPECS: Readonly> =
+ {
+ light: {
+ baseTarget: "#ffffff",
+ baseWeight: 0.8,
+ accent: {
+ center: [0.72, 0.22],
+ middleOffset: 0.28,
+ middleOpacity: 0.72,
+ endOffset: 0.58,
+ },
+ action: {
+ center: [0.18, 0.82],
+ startOpacity: 0.45,
+ endOffset: 0.55,
+ },
+ scale: 1.1,
+ blurAt56Px: 3,
+ },
+ dark: {
+ baseTarget: "#09090b",
+ baseWeight: 0.8,
+ accent: {
+ center: [0.28, 0.78],
+ middleOffset: 0.28,
+ middleOpacity: 0.62,
+ endOffset: 0.58,
+ },
+ action: {
+ center: [0.82, 0.18],
+ startOpacity: 0.45,
+ endOffset: 0.55,
+ },
+ scale: 1.1,
+ blurAt56Px: 3,
+ },
+ };
+
+type Oklab = Readonly<{ l: number; a: number; b: number }>;
+
+const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)/;
+const HEX_PATTERN = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;
+
+function srgbToLinear(value: number): number {
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
+}
+
+function linearToSrgb(value: number): number {
+ const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
+ return Math.round(Math.min(1, Math.max(0, converted)) * 255);
+}
+
+function parseOklab(value: string): Oklab | null {
+ const oklch = OKLCH_PATTERN.exec(value);
+ if (oklch) {
+ const lightness = Number(oklch[1]);
+ const chroma = Number(oklch[2]);
+ const hue = (Number(oklch[3]) * Math.PI) / 180;
+ return { l: lightness, a: chroma * Math.cos(hue), b: chroma * Math.sin(hue) };
+ }
+
+ const hex = HEX_PATTERN.exec(value);
+ if (!hex) return null;
+ const red = srgbToLinear(Number.parseInt(hex[1]!, 16) / 255);
+ const green = srgbToLinear(Number.parseInt(hex[2]!, 16) / 255);
+ const blue = srgbToLinear(Number.parseInt(hex[3]!, 16) / 255);
+ const lRoot = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue);
+ const mRoot = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue);
+ const sRoot = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue);
+ return {
+ l: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot,
+ a: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot,
+ b: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot,
+ };
+}
+
+function oklabToHex(color: Oklab): string {
+ const lPrime = color.l + 0.3963377774 * color.a + 0.2158037573 * color.b;
+ const mPrime = color.l - 0.1055613458 * color.a - 0.0638541728 * color.b;
+ const sPrime = color.l - 0.0894841775 * color.a - 1.291485548 * color.b;
+ const l = lPrime ** 3;
+ const m = mPrime ** 3;
+ const s = sPrime ** 3;
+ const channels = [
+ linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
+ linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
+ linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
+ ];
+ return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
+}
+
+export function mixThemePreviewBase(colors: ThemePreviewColors, mode: ThemeAppearance): string {
+ const spec = THEME_PREVIEW_RENDER_SPECS[mode]!;
+ const canvas = parseOklab(colors.canvas);
+ const target = parseOklab(spec.baseTarget);
+ if (!canvas || !target) return colors.canvas;
+ const targetWeight = 1 - spec.baseWeight;
+ return oklabToHex({
+ l: canvas.l * spec.baseWeight + target.l * targetWeight,
+ a: canvas.a * spec.baseWeight + target.a * targetWeight,
+ b: canvas.b * spec.baseWeight + target.b * targetWeight,
+ });
+}
diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts
index 7f1123968385..45a9f474fa84 100644
--- a/scripts/mobile-showcase.config.ts
+++ b/scripts/mobile-showcase.config.ts
@@ -1,3 +1,9 @@
+import {
+ MOBILE_DEFAULT_THEME_ID,
+ MOBILE_THEME_IDS,
+ type MobileThemeId,
+} from "@t3tools/shared/themePalettes";
+
import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts";
export { SHOWCASE_SCENES };
@@ -5,6 +11,11 @@ export type { ShowcaseScene };
export type ShowcaseAppearance = "light" | "dark";
+/** Every palette the mobile appearance settings can select. */
+export const SHOWCASE_THEMES = MOBILE_THEME_IDS;
+export const DEFAULT_SHOWCASE_THEME = MOBILE_DEFAULT_THEME_ID;
+export type ShowcaseTheme = MobileThemeId;
+
export interface ShowcaseStoreAssetSpec {
readonly store: "apple" | "google-play";
/** Device directory relative to ShowcaseConfig.outputDirectory. */
@@ -25,6 +36,8 @@ export interface ShowcaseIosDevice {
readonly simulatorDeviceType?: string;
/** Appearance used when the CLI does not pass --appearance. */
readonly appearance: ShowcaseAppearance;
+ /** Palette used when the CLI does not pass --theme. */
+ readonly theme: ShowcaseTheme;
/** Orientation applied by the capture harness. Defaults to portrait. */
readonly orientation?: "portrait" | "landscape";
readonly scenes: ReadonlyArray;
@@ -38,6 +51,8 @@ export interface ShowcaseAndroidDevice {
readonly avd: string;
/** Appearance used when the CLI does not pass --appearance. */
readonly appearance: ShowcaseAppearance;
+ /** Palette used when the CLI does not pass --theme. */
+ readonly theme: ShowcaseTheme;
/** Native ABI used by the AVD, from its config.ini `abi.type`. */
readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a";
readonly scenes: ReadonlyArray;
@@ -92,6 +107,7 @@ const config: ShowcaseConfig = {
simulator: "iPhone 17 Pro Max",
simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max",
appearance: "dark",
+ theme: DEFAULT_SHOWCASE_THEME,
scenes: ["thread", "terminal", "review", "threads", "environments"],
storeAsset: {
store: "apple",
@@ -108,6 +124,7 @@ const config: ShowcaseConfig = {
simulator: "T3 Showcase iPhone 14 Plus",
simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus",
appearance: "dark",
+ theme: DEFAULT_SHOWCASE_THEME,
scenes: ["thread", "terminal", "review", "threads", "environments"],
storeAsset: {
store: "apple",
@@ -124,6 +141,7 @@ const config: ShowcaseConfig = {
simulator: "iPad Pro 13-inch (M5)",
simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB",
appearance: "dark",
+ theme: DEFAULT_SHOWCASE_THEME,
orientation: "landscape",
scenes: ["thread", "terminal", "review", "threads", "environments"],
storeAsset: {
@@ -143,6 +161,7 @@ const config: ShowcaseConfig = {
// Blacksmith Linux runner can use KVM acceleration.
abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
appearance: "dark",
+ theme: DEFAULT_SHOWCASE_THEME,
viewport: {
width: 1080,
height: 1920,
@@ -165,6 +184,7 @@ const config: ShowcaseConfig = {
avd: "Pixel_10_Pro",
abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
appearance: "dark",
+ theme: DEFAULT_SHOWCASE_THEME,
viewport: {
width: 1080,
height: 1920,
@@ -187,6 +207,7 @@ const config: ShowcaseConfig = {
avd: "Pixel_10_Pro",
abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI),
appearance: "dark",
+ theme: DEFAULT_SHOWCASE_THEME,
viewport: {
width: 1440,
height: 2560,
diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts
index d061ff8f95f3..53ed954c5e59 100644
--- a/scripts/mobile-showcase.test.ts
+++ b/scripts/mobile-showcase.test.ts
@@ -2,7 +2,9 @@ import { assert, it } from "@effect/vitest";
import { PNG } from "pngjs";
import showcaseConfig, {
+ DEFAULT_SHOWCASE_THEME,
resolveShowcaseAndroidAbi,
+ SHOWCASE_THEMES,
type ShowcaseConfig,
type ShowcaseStoreAssetSpec,
} from "./mobile-showcase.config.ts";
@@ -56,6 +58,7 @@ const config: ShowcaseConfig = {
platform: "ios",
simulator: "iPhone Test",
appearance: "dark",
+ theme: "t3-code",
scenes: ["thread", "review"],
storeAsset: appleSpec,
},
@@ -64,6 +67,7 @@ const config: ShowcaseConfig = {
platform: "android",
avd: "Pixel_Test",
appearance: "light",
+ theme: "t3-code",
scenes: ["thread", "terminal"],
storeAsset: googleSpec,
},
@@ -96,6 +100,23 @@ it("rejects unsupported system appearances", () => {
);
});
+it("parses repeatable and expanded theme filters", () => {
+ assert.deepStrictEqual(
+ [...parseShowcaseCliArgs(["--theme", "ocean", "--theme", "ember"]).themes],
+ ["ocean", "ember"],
+ );
+ assert.deepStrictEqual(
+ [...parseShowcaseCliArgs(["--theme", "all"]).themes],
+ [...SHOWCASE_THEMES],
+ );
+});
+
+// The app normalizes an unknown id back to its default palette, so a typo here
+// would otherwise produce screenshots labeled with a theme they do not show.
+it("rejects unsupported themes instead of capturing the default palette", () => {
+ assert.throws(() => parseShowcaseCliArgs(["--theme", "sunset"]), /Unsupported theme 'sunset'/u);
+});
+
it("parses validation-only mode", () => {
assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true);
});
@@ -147,8 +168,35 @@ it("expands both appearances into independent upload-ready directories", () => {
directory: showcaseCaptureDirectory("/captures", capture),
})),
[
- { appearance: "light", directory: "/captures/apple/iphone-test/light" },
- { appearance: "dark", directory: "/captures/apple/iphone-test/dark" },
+ { appearance: "light", directory: "/captures/apple/iphone-test/light/t3-code" },
+ { appearance: "dark", directory: "/captures/apple/iphone-test/dark/t3-code" },
+ ],
+ );
+});
+
+// Every palette needs its own leaf folder: one directory holding several themes
+// would mix upload slots and break the per-store screenshot count limits.
+it("expands themes into independent upload-ready directories per appearance", () => {
+ const options = parseShowcaseCliArgs([
+ "--device",
+ "phone",
+ "--appearance",
+ "both",
+ "--theme",
+ "ocean",
+ "--theme",
+ "ember",
+ ]);
+
+ assert.deepStrictEqual(
+ planShowcaseCaptures(config, options).map((capture) =>
+ showcaseCaptureDirectory("/captures", capture),
+ ),
+ [
+ "/captures/apple/iphone-test/light/ocean",
+ "/captures/apple/iphone-test/light/ember",
+ "/captures/apple/iphone-test/dark/ocean",
+ "/captures/apple/iphone-test/dark/ember",
],
);
});
@@ -212,6 +260,14 @@ it("enforces store screenshot count limits", () => {
assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u);
});
+it("defaults every device to the app's own palette", () => {
+ assert.equal(DEFAULT_SHOWCASE_THEME, "t3-code");
+ assert.equal(
+ showcaseConfig.devices.every((device) => device.theme === DEFAULT_SHOWCASE_THEME),
+ true,
+ );
+});
+
it("configures every default device with an exact upload-ready store target", () => {
assert.deepStrictEqual(
showcaseConfig.devices.map((device) => [
diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts
index d9991f423612..8f987b584d9d 100644
--- a/scripts/mobile-showcase.ts
+++ b/scripts/mobile-showcase.ts
@@ -20,6 +20,8 @@ import showcaseConfig, {
type ShowcaseStoreAssetSpec,
SHOWCASE_SCENES,
type ShowcaseScene,
+ SHOWCASE_THEMES,
+ type ShowcaseTheme,
} from "./mobile-showcase.config.ts";
import {
SHOWCASE_ENVIRONMENTS,
@@ -76,6 +78,7 @@ interface CliOptions {
readonly deviceIds: ReadonlySet;
readonly scenes: ReadonlySet;
readonly appearances: ReadonlySet;
+ readonly themes: ReadonlySet;
readonly skipBuild: boolean;
readonly skipMetro: boolean;
readonly keepRunning: boolean;
@@ -87,6 +90,7 @@ export interface ShowcaseCapture {
readonly device: ShowcaseDevice;
readonly scenes: ReadonlyArray;
readonly appearance: ShowcaseAppearance;
+ readonly theme: ShowcaseTheme;
}
interface IosCaptureCleanup {
@@ -223,9 +227,16 @@ export function validateStoreAssetCount(
export function showcaseCaptureDirectory(
outputDirectory: string,
- capture: Pick,
+ capture: Pick,
): string {
- return NodePath.join(outputDirectory, capture.device.storeAsset.directory, capture.appearance);
+ // Each palette owns a leaf folder so one upload slot never mixes themes and
+ // every folder keeps a store-legal screenshot count of its own.
+ return NodePath.join(
+ outputDirectory,
+ capture.device.storeAsset.directory,
+ capture.appearance,
+ capture.theme,
+ );
}
async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise {
@@ -276,6 +287,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions {
const deviceIds = new Set();
const scenes = new Set();
const appearances = new Set();
+ const themes = new Set();
let skipBuild = false;
let skipMetro = false;
let keepRunning = false;
@@ -318,6 +330,18 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions {
appearances.add(value);
}
index += 1;
+ } else if (argument === "--theme") {
+ const value = argumentValue(args, index, argument);
+ if (value === "all") {
+ for (const theme of SHOWCASE_THEMES) themes.add(theme);
+ } else if (SHOWCASE_THEMES.some((theme) => theme === value)) {
+ themes.add(value as ShowcaseTheme);
+ } else {
+ // The app silently falls back to its default palette for an unknown id,
+ // so reject it here rather than shipping a mislabeled screenshot.
+ throw new Error(`Unsupported theme '${value}'. Use ${SHOWCASE_THEMES.join(", ")}, or all.`);
+ }
+ index += 1;
} else if (argument === "--skip-build") {
skipBuild = true;
} else if (argument === "--skip-metro") {
@@ -340,6 +364,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions {
deviceIds,
scenes,
appearances,
+ themes,
skipBuild,
skipMetro,
keepRunning,
@@ -350,7 +375,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions {
export function planShowcaseCaptures(
config: ShowcaseConfig,
- options: Pick,
+ options: Pick,
): ReadonlyArray {
const captures = config.devices
.filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform))
@@ -358,14 +383,18 @@ export function planShowcaseCaptures(
.flatMap((device) => {
const appearances =
options.appearances.size === 0 ? [device.appearance] : options.appearances;
- return [...appearances].map((appearance) => ({
- device,
- appearance,
- scenes:
- options.scenes.size === 0
- ? device.scenes
- : device.scenes.filter((scene) => options.scenes.has(scene)),
- }));
+ const themes = options.themes.size === 0 ? [device.theme] : options.themes;
+ return [...appearances].flatMap((appearance) =>
+ [...themes].map((theme) => ({
+ device,
+ appearance,
+ theme,
+ scenes:
+ options.scenes.size === 0
+ ? device.scenes
+ : device.scenes.filter((scene) => options.scenes.has(scene)),
+ })),
+ );
})
.filter((capture) => capture.scenes.length > 0);
@@ -393,6 +422,7 @@ Options:
--scene Capture one scene (repeatable)
--appearance light|dark|both
Override the configured appearance
+ --theme |all Override the configured palette (repeatable)
--skip-build Reuse the existing simulator app / debug APK
--skip-metro Reuse an already running showcase Metro server
--keep-running Leave devices and Metro running after capture
@@ -400,12 +430,13 @@ Options:
--list Print this help and the configured matrix
Scenes: ${SHOWCASE_SCENES.join(", ")}
+Themes: ${SHOWCASE_THEMES.join(", ")}
Configured devices:
${config.devices
.map((device) => {
const target = device.platform === "ios" ? device.simulator : device.avd;
- 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(", ")}]`;
+ 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.theme}) [${device.scenes.join(", ")}]`;
})
.join("\n")}
`);
@@ -946,6 +977,8 @@ async function captureIos(
JSON.stringify(pairingUrls),
"--showcaseScene",
firstScene,
+ "--showcaseTheme",
+ capture.theme,
// The app rotates itself; Simulator menu UI scripting needs macOS
// Accessibility permission that CI runners do not grant to osascript.
"--showcaseOrientation",
@@ -1204,6 +1237,9 @@ async function captureAndroid(
"--es",
"showcaseScene",
firstScene,
+ "--es",
+ "showcaseTheme",
+ capture.theme,
ANDROID_PACKAGE,
]);
for (const [sceneIndex, scene] of capture.scenes.entries()) {