| author | |
| committer | |
| log | d23b181da0cee78bcc327b6673218b776a3cb023 |
| tree | 20fc4620d1c48487f9d6d29f5cd40a4b0e3c1f0a |
| parent | 3583cd27dccda1b35fe80d9411a95a7217cc8176 |
| signature | Signed by PGP key B5690EEEBB952194 |
76 files changed, 3090 insertions(+), 838 deletions(-)
.github/workflows/mobile-showcase-screenshots.yml+23-6| ... | @@ -21,6 +21,19 @@ on: | ... | @@ -21,6 +21,19 @@ on: |
| 21 | - both | 21 | - both |
| 22 | - dark | 22 | - dark |
| 23 | - light | 23 | - light |
| 24 | theme: | ||
| 25 | description: Palette to capture (all multiplies the run by six) | ||
| 26 | required: true | ||
| 27 | default: t3-code | ||
| 28 | type: choice | ||
| 29 | options: | ||
| 30 | - t3-code | ||
| 31 | - t3-chat | ||
| 32 | - grove | ||
| 33 | - ocean | ||
| 34 | - ember | ||
| 35 | - iris | ||
| 36 | - all | ||
| 24 | 37 | ||
| 25 | permissions: | 38 | permissions: |
| 26 | contents: read | 39 | contents: read |
| ... | @@ -33,7 +46,9 @@ jobs: | ... | @@ -33,7 +46,9 @@ jobs: |
| 33 | name: iPhone 6.9, iPhone 6.5, and iPad 13 | 46 | name: iPhone 6.9, iPhone 6.5, and iPad 13 |
| 34 | if: inputs.platform == 'all' || inputs.platform == 'ios' | 47 | if: inputs.platform == 'all' || inputs.platform == 'ios' |
| 35 | runs-on: blacksmith-12vcpu-macos-26 | 48 | runs-on: blacksmith-12vcpu-macos-26 |
| 36 | timeout-minutes: 60 | 49 | # Capturing every palette multiplies the device matrix by six, and only the |
| 50 | # one native build is shared between them. | ||
| 51 | timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} | ||
| 37 | steps: | 52 | steps: |
| 38 | - name: Checkout | 53 | - name: Checkout |
| 39 | uses: actions/checkout@v6 | 54 | uses: actions/checkout@v6 |
| ... | @@ -62,10 +77,10 @@ jobs: | ... | @@ -62,10 +77,10 @@ jobs: |
| 62 | "$vp_pnpm_bin/pnpm" --version | 77 | "$vp_pnpm_bin/pnpm" --version |
| 63 | 78 | ||
| 64 | - name: Capture iOS showcase | 79 | - name: Capture iOS showcase |
| 65 | run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" | 80 | run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" |
| 66 | 81 | ||
| 67 | - name: Validate App Store Connect assets | 82 | - name: Validate App Store Connect assets |
| 68 | run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only | 83 | run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only |
| 69 | 84 | ||
| 70 | - name: Upload iOS screenshots | 85 | - name: Upload iOS screenshots |
| 71 | if: always() | 86 | if: always() |
| ... | @@ -80,7 +95,9 @@ jobs: | ... | @@ -80,7 +95,9 @@ jobs: |
| 80 | name: Android phone, 7-inch tablet, and 10-inch tablet | 95 | name: Android phone, 7-inch tablet, and 10-inch tablet |
| 81 | if: inputs.platform == 'all' || inputs.platform == 'android' | 96 | if: inputs.platform == 'all' || inputs.platform == 'android' |
| 82 | runs-on: blacksmith-16vcpu-ubuntu-2404 | 97 | runs-on: blacksmith-16vcpu-ubuntu-2404 |
| 83 | timeout-minutes: 60 | 98 | # Capturing every palette multiplies the device matrix by six, and only the |
| 99 | # one native build is shared between them. | ||
| 100 | timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} | ||
| 84 | env: | 101 | env: |
| 85 | T3_SHOWCASE_ANDROID_ABI: x86_64 | 102 | T3_SHOWCASE_ANDROID_ABI: x86_64 |
| 86 | steps: | 103 | steps: |
| ... | @@ -137,10 +154,10 @@ jobs: | ... | @@ -137,10 +154,10 @@ jobs: |
| 137 | cores: 8 | 154 | cores: 8 |
| 138 | ram-size: 4096M | 155 | ram-size: 4096M |
| 139 | disable-animations: false | 156 | disable-animations: false |
| 140 | script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" | 157 | script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" |
| 141 | 158 | ||
| 142 | - name: Validate Google Play assets | 159 | - name: Validate Google Play assets |
| 143 | run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only | 160 | run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" --validate-only |
| 144 | 161 | ||
| 145 | - name: Upload Android screenshots | 162 | - name: Upload Android screenshots |
| 146 | if: always() | 163 | if: always() |
apps/mobile/global.css+15-5| ... | @@ -12,6 +12,7 @@ | ... | @@ -12,6 +12,7 @@ |
| 12 | /* Page backgrounds */ | 12 | /* Page backgrounds */ |
| 13 | --color-screen: #f2f2f7; | 13 | --color-screen: #f2f2f7; |
| 14 | --color-sheet: rgba(242, 242, 247, 0.98); | 14 | --color-sheet: rgba(242, 242, 247, 0.98); |
| 15 | --color-sheet-solid: #f2f2f7; | ||
| 15 | 16 | ||
| 16 | /* Card / surface */ | 17 | /* Card / surface */ |
| 17 | --color-card: #ffffff; | 18 | --color-card: #ffffff; |
| ... | @@ -39,13 +40,16 @@ | ... | @@ -39,13 +40,16 @@ |
| 39 | /* Primary action */ | 40 | /* Primary action */ |
| 40 | --color-primary: #262626; | 41 | --color-primary: #262626; |
| 41 | --color-primary-foreground: #ffffff; | 42 | --color-primary-foreground: #ffffff; |
| 42 | --color-primary-shadow: rgba(0, 0, 0, 0.18); | 43 | --color-primary-shadow: #000000; |
| 43 | 44 | ||
| 44 | /* Secondary action */ | 45 | /* Secondary action */ |
| 45 | --color-secondary: #ffffff; | 46 | --color-secondary: #ffffff; |
| 46 | --color-secondary-foreground: #262626; | 47 | --color-secondary-foreground: #262626; |
| 47 | --color-secondary-border: rgba(0, 0, 0, 0.08); | 48 | --color-secondary-border: rgba(0, 0, 0, 0.08); |
| 48 | --color-switch-active: #34c759; | 49 | --color-switch-active-track: #34c759; |
| 50 | --color-switch-active-thumb: #ffffff; | ||
| 51 | --color-switch-inactive-track: rgba(0, 0, 0, 0.08); | ||
| 52 | --color-switch-inactive-thumb: #8e8e93; | ||
| 49 | 53 | ||
| 50 | /* Danger */ | 54 | /* Danger */ |
| 51 | --color-danger: #fef2f2; | 55 | --color-danger: #fef2f2; |
| ... | @@ -56,7 +60,7 @@ | ... | @@ -56,7 +60,7 @@ |
| 56 | --color-input: #ffffff; | 60 | --color-input: #ffffff; |
| 57 | --color-input-border: rgba(0, 0, 0, 0.1); | 61 | --color-input-border: rgba(0, 0, 0, 0.1); |
| 58 | --color-sidebar-search: rgba(118, 118, 128, 0.12); | 62 | --color-sidebar-search: rgba(118, 118, 128, 0.12); |
| 59 | --color-placeholder: #a3a3a3; | 63 | --color-placeholder: #737373; |
| 60 | 64 | ||
| 61 | /* Icons */ | 65 | /* Icons */ |
| 62 | --color-icon: #262626; | 66 | --color-icon: #262626; |
| ... | @@ -90,6 +94,7 @@ | ... | @@ -90,6 +94,7 @@ |
| 90 | --color-user-bubble: #007aff; | 94 | --color-user-bubble: #007aff; |
| 91 | --color-user-bubble-foreground: #ffffff; | 95 | --color-user-bubble-foreground: #ffffff; |
| 92 | --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78); | 96 | --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78); |
| 97 | --color-user-bubble-skill-foreground: #f0abfc; | ||
| 93 | 98 | ||
| 94 | /* Drawer / modal backdrop */ | 99 | /* Drawer / modal backdrop */ |
| 95 | --color-backdrop: rgba(0, 0, 0, 0.22); | 100 | --color-backdrop: rgba(0, 0, 0, 0.22); |
| ... | @@ -106,6 +111,7 @@ | ... | @@ -106,6 +111,7 @@ |
| 106 | /* Page backgrounds */ | 111 | /* Page backgrounds */ |
| 107 | --color-screen: #0a0a0a; | 112 | --color-screen: #0a0a0a; |
| 108 | --color-sheet: rgba(14, 14, 14, 0.98); | 113 | --color-sheet: rgba(14, 14, 14, 0.98); |
| 114 | --color-sheet-solid: #0e0e0e; | ||
| 109 | 115 | ||
| 110 | /* Card / surface */ | 116 | /* Card / surface */ |
| 111 | --color-card: #171717; | 117 | --color-card: #171717; |
| ... | @@ -133,13 +139,16 @@ | ... | @@ -133,13 +139,16 @@ |
| 133 | /* Primary action */ | 139 | /* Primary action */ |
| 134 | --color-primary: #f5f5f5; | 140 | --color-primary: #f5f5f5; |
| 135 | --color-primary-foreground: #0a0a0a; | 141 | --color-primary-foreground: #0a0a0a; |
| 136 | --color-primary-shadow: rgba(0, 0, 0, 0.22); | 142 | --color-primary-shadow: #000000; |
| 137 | 143 | ||
| 138 | /* Secondary action */ | 144 | /* Secondary action */ |
| 139 | --color-secondary: rgba(255, 255, 255, 0.04); | 145 | --color-secondary: rgba(255, 255, 255, 0.04); |
| 140 | --color-secondary-foreground: #f5f5f5; | 146 | --color-secondary-foreground: #f5f5f5; |
| 141 | --color-secondary-border: rgba(255, 255, 255, 0.06); | 147 | --color-secondary-border: rgba(255, 255, 255, 0.06); |
| 142 | --color-switch-active: #30d158; | 148 | --color-switch-active-track: #30d158; |
| 149 | --color-switch-active-thumb: #ffffff; | ||
| 150 | --color-switch-inactive-track: rgba(255, 255, 255, 0.06); | ||
| 151 | --color-switch-inactive-thumb: #8e8e93; | ||
| 143 | 152 | ||
| 144 | /* Danger */ | 153 | /* Danger */ |
| 145 | --color-danger: rgba(239, 68, 68, 0.14); | 154 | --color-danger: rgba(239, 68, 68, 0.14); |
| ... | @@ -184,6 +193,7 @@ | ... | @@ -184,6 +193,7 @@ |
| 184 | --color-user-bubble: #0a84ff; | 193 | --color-user-bubble: #0a84ff; |
| 185 | --color-user-bubble-foreground: #ffffff; | 194 | --color-user-bubble-foreground: #ffffff; |
| 186 | --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78); | 195 | --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78); |
| 196 | --color-user-bubble-skill-foreground: #f0abfc; | ||
| 187 | 197 | ||
| 188 | /* Drawer / modal backdrop */ | 198 | /* Drawer / modal backdrop */ |
| 189 | --color-backdrop: rgba(0, 0, 0, 0.48); | 199 | --color-backdrop: rgba(0, 0, 0, 0.48); |
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt+6| ... | @@ -22,6 +22,12 @@ class T3NativeControlsModule : Module() { | ... | @@ -22,6 +22,12 @@ class T3NativeControlsModule : Module() { |
| 22 | storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene") | 22 | storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene") |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | // The palette is fixed for the whole capture, so it only ever arrives as a | ||
| 26 | // launch extra — unlike the scene, which the runner rewrites in place. | ||
| 27 | Function("getShowcaseTheme") { | ||
| 28 | appContext.currentActivity?.intent?.getStringExtra("showcaseTheme") | ||
| 29 | } | ||
| 30 | |||
| 25 | Function("prepareShowcaseCapture") { | 31 | Function("prepareShowcaseCapture") { |
| 26 | // Android app data is cleared by the host runner before launch. | 32 | // Android app data is cleared by the host runner before launch. |
| 27 | } | 33 | } |
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+13| ... | @@ -33,6 +33,19 @@ public final class T3NativeControlsModule: Module { | ... | @@ -33,6 +33,19 @@ public final class T3NativeControlsModule: Module { |
| 33 | return arguments[flagIndex + 1] | 33 | return arguments[flagIndex + 1] |
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | // The palette is fixed for the whole capture, so it only ever arrives as a | ||
| 37 | // launch argument — unlike the scene, which the runner rewrites in place. | ||
| 38 | Function("getShowcaseTheme") { () -> String? in | ||
| 39 | let arguments = ProcessInfo.processInfo.arguments | ||
| 40 | guard | ||
| 41 | let flagIndex = arguments.firstIndex(of: "--showcaseTheme"), | ||
| 42 | arguments.indices.contains(flagIndex + 1) | ||
| 43 | else { | ||
| 44 | return nil as String? | ||
| 45 | } | ||
| 46 | return arguments[flagIndex + 1] | ||
| 47 | } | ||
| 48 | |||
| 36 | Function("getShowcaseOrientation") { () -> String? in | 49 | Function("getShowcaseOrientation") { () -> String? in |
| 37 | let arguments = ProcessInfo.processInfo.arguments | 50 | let arguments = ProcessInfo.processInfo.arguments |
| 38 | guard | 51 | guard |
apps/mobile/src/App.tsx+42-35| ... | @@ -2,11 +2,11 @@ import { BlurTargetView } from "expo-blur"; | ... | @@ -2,11 +2,11 @@ import { BlurTargetView } from "expo-blur"; |
| 2 | import * as Linking from "expo-linking"; | 2 | import * as Linking from "expo-linking"; |
| 3 | import * as SplashScreen from "expo-splash-screen"; | 3 | import * as SplashScreen from "expo-splash-screen"; |
| 4 | import { useEffect } from "react"; | 4 | import { useEffect } from "react"; |
| 5 | import { StatusBar, useColorScheme } from "react-native"; | 5 | import { StatusBar } from "react-native"; |
| 6 | import { GestureHandlerRootView } from "react-native-gesture-handler"; | 6 | import { GestureHandlerRootView } from "react-native-gesture-handler"; |
| 7 | import { KeyboardProvider } from "react-native-keyboard-controller"; | 7 | import { KeyboardProvider } from "react-native-keyboard-controller"; |
| 8 | import { SafeAreaProvider } from "react-native-safe-area-context"; | 8 | import { SafeAreaProvider } from "react-native-safe-area-context"; |
| 9 | import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native"; | 9 | import { createStaticNavigation } from "@react-navigation/native"; |
| 10 | 10 | ||
| 11 | import { RegistryContext } from "@effect/atom-react"; | 11 | import { RegistryContext } from "@effect/atom-react"; |
| 12 | import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; | 12 | import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; |
| ... | @@ -22,6 +22,7 @@ import { appAtomRegistry } from "./state/atom-registry"; | ... | @@ -22,6 +22,7 @@ import { appAtomRegistry } from "./state/atom-registry"; |
| 22 | import { OverlayPortalHost } from "./components/OverlayPortal"; | 22 | import { OverlayPortalHost } from "./components/OverlayPortal"; |
| 23 | import { appBlurTargetRef } from "./lib/appBlurTarget"; | 23 | import { appBlurTargetRef } from "./lib/appBlurTarget"; |
| 24 | import { useThemeColor } from "./lib/useThemeColor"; | 24 | import { useThemeColor } from "./lib/useThemeColor"; |
| 25 | import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; | ||
| 25 | 26 | ||
| 26 | import "../global.css"; | 27 | import "../global.css"; |
| 27 | 28 | ||
| ... | @@ -58,45 +59,51 @@ function SplashScreenCoordinator() { | ... | @@ -58,45 +59,51 @@ function SplashScreenCoordinator() { |
| 58 | } | 59 | } |
| 59 | 60 | ||
| 60 | export default function App() { | 61 | export default function App() { |
| 61 | const colorScheme = useColorScheme(); | ||
| 62 | const statusBarBg = useThemeColor("--color-status-bar"); | ||
| 63 | |||
| 64 | return ( | 62 | return ( |
| 65 | <RegistryContext.Provider value={appAtomRegistry}> | 63 | <RegistryContext.Provider value={appAtomRegistry}> |
| 66 | <CloudAuthProvider> | 64 | <CloudAuthProvider> |
| 67 | <AppearancePreferencesProvider> | 65 | <AppearancePreferencesProvider> |
| 68 | <SplashScreenCoordinator /> | 66 | <AppContent /> |
| 69 | <GestureHandlerRootView className="flex-1"> | ||
| 70 | <KeyboardProvider statusBarTranslucent> | ||
| 71 | <SafeAreaProvider> | ||
| 72 | <StatusBar | ||
| 73 | barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} | ||
| 74 | backgroundColor={statusBarBg} | ||
| 75 | translucent | ||
| 76 | /> | ||
| 77 | {/* The navigation theme drives the NATIVE header appearance: native-stack | ||
| 78 | forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without | ||
| 79 | this, React Navigation defaults to its light theme and every native | ||
| 80 | header (glass buttons, title, materials) is forced light even when | ||
| 81 | the system is in dark mode. */} | ||
| 82 | {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} | ||
| 83 | <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}> | ||
| 84 | <IncomingShareProvider> | ||
| 85 | <Navigation | ||
| 86 | linking={appLinking} | ||
| 87 | theme={colorScheme === "dark" ? DarkTheme : DefaultTheme} | ||
| 88 | /> | ||
| 89 | </IncomingShareProvider> | ||
| 90 | <ConfirmDialogHost /> | ||
| 91 | </BlurTargetView> | ||
| 92 | {/* Anchored-menu overlays render here — in-window, so the | ||
| 93 | keyboard stays up while a dropdown is open. */} | ||
| 94 | <OverlayPortalHost /> | ||
| 95 | </SafeAreaProvider> | ||
| 96 | </KeyboardProvider> | ||
| 97 | </GestureHandlerRootView> | ||
| 98 | </AppearancePreferencesProvider> | 67 | </AppearancePreferencesProvider> |
| 99 | </CloudAuthProvider> | 68 | </CloudAuthProvider> |
| 100 | </RegistryContext.Provider> | 69 | </RegistryContext.Provider> |
| 101 | ); | 70 | ); |
| 102 | } | 71 | } |
| 72 | |||
| 73 | function AppContent() { | ||
| 74 | const { themeAppearance } = useAppearancePreferences(); | ||
| 75 | const statusBarBg = useThemeColor("--color-status-bar"); | ||
| 76 | const navigationTheme = useMobileNavigationTheme(themeAppearance); | ||
| 77 | |||
| 78 | return ( | ||
| 79 | <> | ||
| 80 | <SplashScreenCoordinator /> | ||
| 81 | <GestureHandlerRootView className="flex-1"> | ||
| 82 | <KeyboardProvider statusBarTranslucent> | ||
| 83 | <SafeAreaProvider> | ||
| 84 | <StatusBar | ||
| 85 | barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"} | ||
| 86 | backgroundColor={statusBarBg} | ||
| 87 | translucent | ||
| 88 | /> | ||
| 89 | {/* The navigation theme drives the NATIVE header appearance: native-stack | ||
| 90 | forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without | ||
| 91 | this, React Navigation defaults to its light theme and every native | ||
| 92 | header (glass buttons, title, materials) is forced light even when | ||
| 93 | the system is in dark mode. */} | ||
| 94 | {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} | ||
| 95 | <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}> | ||
| 96 | <IncomingShareProvider> | ||
| 97 | <Navigation linking={appLinking} theme={navigationTheme} /> | ||
| 98 | </IncomingShareProvider> | ||
| 99 | <ConfirmDialogHost /> | ||
| 100 | </BlurTargetView> | ||
| 101 | {/* Anchored-menu overlays render here — in-window, so the | ||
| 102 | keyboard stays up while a dropdown is open. */} | ||
| 103 | <OverlayPortalHost /> | ||
| 104 | </SafeAreaProvider> | ||
| 105 | </KeyboardProvider> | ||
| 106 | </GestureHandlerRootView> | ||
| 107 | </> | ||
| 108 | ); | ||
| 109 | } |
apps/mobile/src/Stack.tsx+8-19| ... | @@ -11,7 +11,7 @@ import { | ... | @@ -11,7 +11,7 @@ import { |
| 11 | type NativeStackNavigationOptions, | 11 | type NativeStackNavigationOptions, |
| 12 | } from "@react-navigation/native-stack"; | 12 | } from "@react-navigation/native-stack"; |
| 13 | import { useEffect, useRef } from "react"; | 13 | import { useEffect, useRef } from "react"; |
| 14 | import { Platform, Pressable, ScrollView, StyleSheet } from "react-native"; | 14 | import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native"; |
| 15 | import { useResolveClassNames } from "uniwind"; | 15 | import { useResolveClassNames } from "uniwind"; |
| 16 | 16 | ||
| 17 | import { AppText as Text } from "./components/AppText"; | 17 | import { AppText as Text } from "./components/AppText"; |
| ... | @@ -71,11 +71,7 @@ import { | ... | @@ -71,11 +71,7 @@ import { |
| 71 | } from "./features/sharing/incoming-share-presentation"; | 71 | } from "./features/sharing/incoming-share-presentation"; |
| 72 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; | 72 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; |
| 73 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; | 73 | import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; |
| 74 | import { | 74 | import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; |
| 75 | FORM_SHEET_PRESENTATION_OPTIONS, | ||
| 76 | NATIVE_SHEET_SURFACE_COLOR, | ||
| 77 | NATIVE_SHEET_SURFACE_CONTENT_STYLE, | ||
| 78 | } from "./native/sheet-surface"; | ||
| 79 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; | 75 | import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; |
| 80 | 76 | ||
| 81 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | 77 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| ... | @@ -96,11 +92,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { | ... | @@ -96,11 +92,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { |
| 96 | headerLargeTitle: false, | 92 | headerLargeTitle: false, |
| 97 | headerShadowVisible: false, | 93 | headerShadowVisible: false, |
| 98 | headerShown: true, | 94 | headerShown: true, |
| 99 | headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED | 95 | headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, |
| 100 | ? { backgroundColor: "transparent" } | ||
| 101 | : NATIVE_SHEET_SURFACE_COLOR !== undefined | ||
| 102 | ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } | ||
| 103 | : undefined, | ||
| 104 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, | 96 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, |
| 105 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, | 97 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, |
| 106 | scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined, | 98 | scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined, |
| ... | @@ -115,12 +107,6 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = { | ... | @@ -115,12 +107,6 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = { |
| 115 | headerLargeTitle: false, | 107 | headerLargeTitle: false, |
| 116 | headerShadowVisible: false, | 108 | headerShadowVisible: false, |
| 117 | headerShown: true, | 109 | headerShown: true, |
| 118 | headerStyle: | ||
| 119 | NATIVE_SHEET_SURFACE_COLOR !== undefined | ||
| 120 | ? // native-stack types this as `string`, but the native side accepts any | ||
| 121 | // ColorValue including DynamicColorIOS. | ||
| 122 | { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string } | ||
| 123 | : undefined, | ||
| 124 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, | 110 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, |
| 125 | headerTransparent: false, | 111 | headerTransparent: false, |
| 126 | unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, | 112 | unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, |
| ... | @@ -507,7 +493,6 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -507,7 +493,6 @@ export const RootStack = createNativeStackNavigator({ |
| 507 | linking: `${THREAD_LINKING_PREFIX}/files`, | 493 | linking: `${THREAD_LINKING_PREFIX}/files`, |
| 508 | options: { | 494 | options: { |
| 509 | ...GLASS_HEADER_OPTIONS, | 495 | ...GLASS_HEADER_OPTIONS, |
| 510 | contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE, | ||
| 511 | title: "Files", | 496 | title: "Files", |
| 512 | }, | 497 | }, |
| 513 | }), | 498 | }), |
| ... | @@ -636,7 +621,11 @@ export const RootStack = createNativeStackNavigator({ | ... | @@ -636,7 +621,11 @@ export const RootStack = createNativeStackNavigator({ |
| 636 | // The whole new-task flow (choose project → draft → add project) shares | 621 | // The whole new-task flow (choose project → draft → add project) shares |
| 637 | // draft state via NewTaskFlowProvider. The expo-router era mounted it in | 622 | // draft state via NewTaskFlowProvider. The expo-router era mounted it in |
| 638 | // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent. | 623 | // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent. |
| 639 | layout: ({ children }) => <NewTaskFlowProvider>{children}</NewTaskFlowProvider>, | 624 | layout: ({ children }) => ( |
| 625 | <NewTaskFlowProvider> | ||
| 626 | <View className="flex-1 bg-sheet-solid">{children}</View> | ||
| 627 | </NewTaskFlowProvider> | ||
| 628 | ), | ||
| 640 | options: { | 629 | options: { |
| 641 | gestureEnabled: true, | 630 | gestureEnabled: true, |
| 642 | headerShown: false, | 631 | headerShown: false, |
apps/mobile/src/components/AndroidAnchoredMenu.tsx+4-2| ... | @@ -3,11 +3,12 @@ import { BlurView } from "expo-blur"; | ... | @@ -3,11 +3,12 @@ import { BlurView } from "expo-blur"; |
| 3 | import type { ReactNode } from "react"; | 3 | import type { ReactNode } from "react"; |
| 4 | import { useCallback, useEffect, useRef, useState } from "react"; | 4 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 5 | import type { StyleProp, ViewStyle } from "react-native"; | 5 | import type { StyleProp, ViewStyle } from "react-native"; |
| 6 | import { BackHandler, Pressable, ScrollView, useColorScheme, View } from "react-native"; | 6 | import { BackHandler, Pressable, ScrollView, View } from "react-native"; |
| 7 | import { useKeyboardState } from "react-native-keyboard-controller"; | 7 | import { useKeyboardState } from "react-native-keyboard-controller"; |
| 8 | import Animated, { FadeIn } from "react-native-reanimated"; | 8 | import Animated, { FadeIn } from "react-native-reanimated"; |
| 9 | 9 | ||
| 10 | import { appBlurTargetRef } from "../lib/appBlurTarget"; | 10 | import { appBlurTargetRef } from "../lib/appBlurTarget"; |
| 11 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | ||
| 11 | import { useThemeColor } from "../lib/useThemeColor"; | 12 | import { useThemeColor } from "../lib/useThemeColor"; |
| 12 | import { cn } from "../lib/cn"; | 13 | import { cn } from "../lib/cn"; |
| 13 | import { type AppSymbolName, SymbolView } from "./AppSymbol"; | 14 | import { type AppSymbolName, SymbolView } from "./AppSymbol"; |
| ... | @@ -79,7 +80,8 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { | ... | @@ -79,7 +80,8 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { |
| 79 | const anchorRef = useRef<View>(null); | 80 | const anchorRef = useRef<View>(null); |
| 80 | const overlayRef = useRef<View>(null); | 81 | const overlayRef = useRef<View>(null); |
| 81 | 82 | ||
| 82 | const isDarkMode = useColorScheme() === "dark"; | 83 | const { themeAppearance } = useAppearancePreferences(); |
| 84 | const isDarkMode = themeAppearance === "dark"; | ||
| 83 | const keyboardVisible = useKeyboardState((state) => state.isVisible); | 85 | const keyboardVisible = useKeyboardState((state) => state.isVisible); |
| 84 | const keyboardHeight = useKeyboardState((state) => state.height); | 86 | const keyboardHeight = useKeyboardState((state) => state.height); |
| 85 | const rippleColor = useThemeColor("--color-subtle"); | 87 | const rippleColor = useThemeColor("--color-subtle"); |
apps/mobile/src/components/AppSymbol.tsx+4| ... | @@ -49,6 +49,7 @@ import { | ... | @@ -49,6 +49,7 @@ import { |
| 49 | IconLink, | 49 | IconLink, |
| 50 | IconMessage, | 50 | IconMessage, |
| 51 | IconMinus, | 51 | IconMinus, |
| 52 | IconMoon, | ||
| 52 | IconNetwork, | 53 | IconNetwork, |
| 53 | IconPalette, | 54 | IconPalette, |
| 54 | IconPin, | 55 | IconPin, |
| ... | @@ -62,6 +63,7 @@ import { | ... | @@ -62,6 +63,7 @@ import { |
| 62 | IconServer, | 63 | IconServer, |
| 63 | IconSettings, | 64 | IconSettings, |
| 64 | IconSparkles, | 65 | IconSparkles, |
| 66 | IconSun, | ||
| 65 | IconLayoutSidebarRight, | 67 | IconLayoutSidebarRight, |
| 66 | IconTerminal2, | 68 | IconTerminal2, |
| 67 | IconTextDecrease, | 69 | IconTextDecrease, |
| ... | @@ -111,6 +113,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -111,6 +113,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 111 | "doc.on.doc": IconCopy, | 113 | "doc.on.doc": IconCopy, |
| 112 | "doc.text": IconFileText, | 114 | "doc.text": IconFileText, |
| 113 | ellipsis: IconDots, | 115 | ellipsis: IconDots, |
| 116 | moon: IconMoon, | ||
| 114 | "ellipsis.circle": IconDotsCircleHorizontal, | 117 | "ellipsis.circle": IconDotsCircleHorizontal, |
| 115 | "exclamationmark.triangle": IconAlertTriangle, | 118 | "exclamationmark.triangle": IconAlertTriangle, |
| 116 | eye: IconEye, | 119 | eye: IconEye, |
| ... | @@ -139,6 +142,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -139,6 +142,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 139 | "slider.horizontal.3": IconAdjustmentsHorizontal, | 142 | "slider.horizontal.3": IconAdjustmentsHorizontal, |
| 140 | "square.and.pencil": IconEdit, | 143 | "square.and.pencil": IconEdit, |
| 141 | "square.split.2x1": IconLayoutColumns, | 144 | "square.split.2x1": IconLayoutColumns, |
| 145 | "sun.max": IconSun, | ||
| 142 | "stop.fill": IconPlayerStopFilled, | 146 | "stop.fill": IconPlayerStopFilled, |
| 143 | terminal: IconTerminal2, | 147 | terminal: IconTerminal2, |
| 144 | "text.bubble": IconMessage, | 148 | "text.bubble": IconMessage, |
apps/mobile/src/components/ComposerToolbar.tsx+8-6| ... | @@ -4,7 +4,6 @@ import { | ... | @@ -4,7 +4,6 @@ import { |
| 4 | Pressable, | 4 | Pressable, |
| 5 | ScrollView, | 5 | ScrollView, |
| 6 | View, | 6 | View, |
| 7 | useColorScheme, | ||
| 8 | type LayoutChangeEvent, | 7 | type LayoutChangeEvent, |
| 9 | type NativeScrollEvent, | 8 | type NativeScrollEvent, |
| 10 | type NativeSyntheticEvent, | 9 | type NativeSyntheticEvent, |
| ... | @@ -13,6 +12,8 @@ import { | ... | @@ -13,6 +12,8 @@ import { |
| 13 | } from "react-native"; | 12 | } from "react-native"; |
| 14 | 13 | ||
| 15 | import { useThemeColor } from "../lib/useThemeColor"; | 14 | import { useThemeColor } from "../lib/useThemeColor"; |
| 15 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | ||
| 16 | import { themeColorWithAlpha } from "../lib/mobileTheme"; | ||
| 16 | import { cn } from "../lib/cn"; | 17 | import { cn } from "../lib/cn"; |
| 17 | import { AppText as Text } from "./AppText"; | 18 | import { AppText as Text } from "./AppText"; |
| 18 | import { SymbolView } from "./AppSymbol"; | 19 | import { SymbolView } from "./AppSymbol"; |
| ... | @@ -213,21 +214,22 @@ export function ComposerToolbarButton(props: { | ... | @@ -213,21 +214,22 @@ export function ComposerToolbarButton(props: { |
| 213 | readonly className?: string; | 214 | readonly className?: string; |
| 214 | readonly style?: StyleProp<ViewStyle>; | 215 | readonly style?: StyleProp<ViewStyle>; |
| 215 | }) { | 216 | }) { |
| 216 | const isDarkMode = useColorScheme() === "dark"; | 217 | const { themeAppearance } = useAppearancePreferences(); |
| 218 | const isDarkMode = themeAppearance === "dark"; | ||
| 217 | const iconColor = useThemeColor("--color-icon"); | 219 | const iconColor = useThemeColor("--color-icon"); |
| 218 | const iconSubtle = useThemeColor("--color-icon-subtle"); | 220 | const iconSubtle = useThemeColor("--color-icon-subtle"); |
| 219 | const primaryFg = useThemeColor("--color-primary-foreground"); | 221 | const primaryFg = useThemeColor("--color-primary-foreground"); |
| 220 | const dangerFg = useThemeColor("--color-danger-foreground"); | 222 | const dangerFg = useThemeColor("--color-danger-foreground"); |
| 221 | const variant = props.variant ?? "default"; | 223 | const variant = props.variant ?? "default"; |
| 222 | const isCircle = !props.label && props.showChevron === false; | 224 | const isCircle = !props.label && props.showChevron === false; |
| 223 | const defaultBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)"; | 225 | const defaultBorderColor = useThemeColor("--color-border-subtle"); |
| 224 | const activeBorderColor = isDarkMode ? "rgba(255,255,255,0.13)" : "rgba(0,0,0,0.1)"; | 226 | const activeBorderColor = useThemeColor("--color-border"); |
| 225 | const filledBorderColor = | 227 | const filledBorderColor = |
| 226 | variant === "danger" | 228 | variant === "danger" |
| 227 | ? "rgba(255,255,255,0.14)" | 229 | ? themeColorWithAlpha(String(dangerFg), 0.14) |
| 228 | : props.disabled | 230 | : props.disabled |
| 229 | ? defaultBorderColor | 231 | ? defaultBorderColor |
| 230 | : "rgba(255,255,255,0.18)"; | 232 | : themeColorWithAlpha(String(primaryFg), 0.18); |
| 231 | const iconTintColor = | 233 | const iconTintColor = |
| 232 | variant === "primary" | 234 | variant === "primary" |
| 233 | ? props.disabled | 235 | ? props.disabled |
apps/mobile/src/components/ControlPill.tsx+4-2| ... | @@ -8,8 +8,9 @@ import { | ... | @@ -8,8 +8,9 @@ import { |
| 8 | type ReactNode, | 8 | type ReactNode, |
| 9 | useRef, | 9 | useRef, |
| 10 | } from "react"; | 10 | } from "react"; |
| 11 | import { Platform, Pressable, useColorScheme, View } from "react-native"; | 11 | import { Platform, Pressable, View } from "react-native"; |
| 12 | import { useThemeColor } from "../lib/useThemeColor"; | 12 | import { useThemeColor } from "../lib/useThemeColor"; |
| 13 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | ||
| 13 | 14 | ||
| 14 | import { cn } from "../lib/cn"; | 15 | import { cn } from "../lib/cn"; |
| 15 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; | 16 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; |
| ... | @@ -117,7 +118,8 @@ export function ControlPillMenu( | ... | @@ -117,7 +118,8 @@ export function ControlPillMenu( |
| 117 | readonly className?: string; | 118 | readonly className?: string; |
| 118 | }, | 119 | }, |
| 119 | ) { | 120 | ) { |
| 120 | const isDarkMode = useColorScheme() === "dark"; | 121 | const { themeAppearance } = useAppearancePreferences(); |
| 122 | const isDarkMode = themeAppearance === "dark"; | ||
| 121 | 123 | ||
| 122 | if (Platform.OS === "android") { | 124 | if (Platform.OS === "android") { |
| 123 | // Long-press menus keep their child interactive: the child element gets | 125 | // Long-press menus keep their child interactive: the child element gets |
apps/mobile/src/components/GlassSurface.tsx+3-2| ... | @@ -2,7 +2,6 @@ import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; | ... | @@ -2,7 +2,6 @@ import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; |
| 2 | import type { ReactNode } from "react"; | 2 | import type { ReactNode } from "react"; |
| 3 | import { | 3 | import { |
| 4 | Platform, | 4 | Platform, |
| 5 | useColorScheme, | ||
| 6 | View, | 5 | View, |
| 7 | type ColorValue, | 6 | type ColorValue, |
| 8 | type StyleProp, | 7 | type StyleProp, |
| ... | @@ -10,6 +9,7 @@ import { | ... | @@ -10,6 +9,7 @@ import { |
| 10 | type ViewStyle, | 9 | type ViewStyle, |
| 11 | } from "react-native"; | 10 | } from "react-native"; |
| 12 | import { useThemeColor } from "../lib/useThemeColor"; | 11 | import { useThemeColor } from "../lib/useThemeColor"; |
| 12 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | ||
| 13 | 13 | ||
| 14 | interface GlassSurfaceProps extends Omit<ViewProps, "className"> { | 14 | interface GlassSurfaceProps extends Omit<ViewProps, "className"> { |
| 15 | readonly children: ReactNode; | 15 | readonly children: ReactNode; |
| ... | @@ -29,7 +29,8 @@ export function GlassSurface({ | ... | @@ -29,7 +29,8 @@ export function GlassSurface({ |
| 29 | style, | 29 | style, |
| 30 | ...props | 30 | ...props |
| 31 | }: GlassSurfaceProps) { | 31 | }: GlassSurfaceProps) { |
| 32 | const isDarkMode = useColorScheme() === "dark"; | 32 | const { themeAppearance } = useAppearancePreferences(); |
| 33 | const isDarkMode = themeAppearance === "dark"; | ||
| 33 | const borderColor = useThemeColor("--color-border"); | 34 | const borderColor = useThemeColor("--color-border"); |
| 34 | const glassSurface = useThemeColor("--color-glass-surface"); | 35 | const glassSurface = useThemeColor("--color-glass-surface"); |
| 35 | const glassTint = useThemeColor("--color-glass-tint"); | 36 | const glassTint = useThemeColor("--color-glass-tint"); |
apps/mobile/src/components/LoadingScreen.tsx+3-2| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native"; | 1 | import { ActivityIndicator, StatusBar, View } from "react-native"; |
| 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 3 | import { useThemeColor } from "../lib/useThemeColor"; | 3 | import { useThemeColor } from "../lib/useThemeColor"; |
| 4 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | ||
| 4 | 5 | ||
| 5 | import { AppText as Text } from "./AppText"; | 6 | import { AppText as Text } from "./AppText"; |
| 6 | import { BrandMark } from "./BrandMark"; | 7 | import { BrandMark } from "./BrandMark"; |
| ... | @@ -9,7 +10,7 @@ export function LoadingScreen(props: { | ... | @@ -9,7 +10,7 @@ export function LoadingScreen(props: { |
| 9 | readonly message: string; | 10 | readonly message: string; |
| 10 | readonly messagePlacement?: "above-spinner" | "below-spinner"; | 11 | readonly messagePlacement?: "above-spinner" | "below-spinner"; |
| 11 | }) { | 12 | }) { |
| 12 | const colorScheme = useColorScheme(); | 13 | const { themeAppearance: colorScheme } = useAppearancePreferences(); |
| 13 | const screenBg = useThemeColor("--color-screen"); | 14 | const screenBg = useThemeColor("--color-screen"); |
| 14 | const insets = useSafeAreaInsets(); | 15 | const insets = useSafeAreaInsets(); |
| 15 | const messagePlacement = props.messagePlacement ?? "below-spinner"; | 16 | const messagePlacement = props.messagePlacement ?? "below-spinner"; |
apps/mobile/src/components/PierreEntryIcon.tsx+3-1| ... | @@ -3,6 +3,7 @@ import { Image, type ImageStyle, type StyleProp } from "react-native"; | ... | @@ -3,6 +3,7 @@ import { Image, type ImageStyle, type StyleProp } from "react-native"; |
| 3 | 3 | ||
| 4 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 4 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 5 | import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; | 5 | import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; |
| 6 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 6 | 7 | ||
| 7 | export function PierreEntryIcon(props: { | 8 | export function PierreEntryIcon(props: { |
| 8 | readonly path: string; | 9 | readonly path: string; |
| ... | @@ -11,8 +12,9 @@ export function PierreEntryIcon(props: { | ... | @@ -11,8 +12,9 @@ export function PierreEntryIcon(props: { |
| 11 | readonly style?: StyleProp<ImageStyle>; | 12 | readonly style?: StyleProp<ImageStyle>; |
| 12 | }) { | 13 | }) { |
| 13 | const size = props.size ?? 16; | 14 | const size = props.size ?? 16; |
| 15 | const folderColor = useThemeColor("--color-icon-subtle"); | ||
| 14 | if (props.kind === "directory") { | 16 | if (props.kind === "directory") { |
| 15 | return <SymbolView name="folder" size={size} tintColor="#a1a1aa" type="monochrome" />; | 17 | return <SymbolView name="folder" size={size} tintColor={folderColor} type="monochrome" />; |
| 16 | } | 18 | } |
| 17 | 19 | ||
| 18 | return ( | 20 | return ( |
apps/mobile/src/components/ProviderIcon.tsx+3-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { useColorScheme } from "react-native"; | ||
| 2 | import { Path, Svg } from "react-native-svg"; | 1 | import { Path, Svg } from "react-native-svg"; |
| 2 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | ||
| 3 | 3 | ||
| 4 | type ProviderIconProps = { | 4 | type ProviderIconProps = { |
| 5 | readonly provider: string | null | undefined; | 5 | readonly provider: string | null | undefined; |
| ... | @@ -7,7 +7,8 @@ type ProviderIconProps = { | ... | @@ -7,7 +7,8 @@ type ProviderIconProps = { |
| 7 | }; | 7 | }; |
| 8 | 8 | ||
| 9 | export function ProviderIcon(props: ProviderIconProps) { | 9 | export function ProviderIcon(props: ProviderIconProps) { |
| 10 | const isDarkMode = useColorScheme() === "dark"; | 10 | const { themeAppearance } = useAppearancePreferences(); |
| 11 | const isDarkMode = themeAppearance === "dark"; | ||
| 11 | const size = props.size ?? 16; | 12 | const size = props.size ?? 16; |
| 12 | const mono = isDarkMode ? "#e5e5e5" : "#171717"; | 13 | const mono = isDarkMode ? "#e5e5e5" : "#171717"; |
| 13 | 14 |
apps/mobile/src/components/ThemedSwitch.tsx created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import { Platform, Switch, type SwitchProps } from "react-native"; | ||
| 2 | |||
| 3 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 4 | |||
| 5 | export function ThemedSwitch(props: SwitchProps) { | ||
| 6 | const activeTrack = String(useThemeColor("--color-switch-active-track")); | ||
| 7 | const inactiveTrack = String(useThemeColor("--color-switch-inactive-track")); | ||
| 8 | const activeThumb = String(useThemeColor("--color-switch-active-thumb")); | ||
| 9 | const inactiveThumb = String(useThemeColor("--color-switch-inactive-thumb")); | ||
| 10 | |||
| 11 | return ( | ||
| 12 | <Switch | ||
| 13 | {...props} | ||
| 14 | ios_backgroundColor={inactiveTrack} | ||
| 15 | thumbColor={ | ||
| 16 | Platform.OS === "android" ? (props.value ? activeThumb : inactiveThumb) : undefined | ||
| 17 | } | ||
| 18 | trackColor={{ false: inactiveTrack, true: activeTrack }} | ||
| 19 | /> | ||
| 20 | ); | ||
| 21 | } | ||
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+2-6| ... | @@ -9,13 +9,13 @@ import { useCallback, useState } from "react"; | ... | @@ -9,13 +9,13 @@ import { useCallback, useState } from "react"; |
| 9 | import { | 9 | import { |
| 10 | ActivityIndicator, | 10 | ActivityIndicator, |
| 11 | Pressable, | 11 | Pressable, |
| 12 | Switch, | ||
| 13 | type NativeSyntheticEvent, | 12 | type NativeSyntheticEvent, |
| 14 | type TextLayoutEventData, | 13 | type TextLayoutEventData, |
| 15 | View, | 14 | View, |
| 16 | } from "react-native"; | 15 | } from "react-native"; |
| 17 | 16 | ||
| 18 | import { AppText as Text } from "../../components/AppText"; | 17 | import { AppText as Text } from "../../components/AppText"; |
| 18 | import { ThemedSwitch } from "../../components/ThemedSwitch"; | ||
| 19 | import { cn } from "../../lib/cn"; | 19 | import { cn } from "../../lib/cn"; |
| 20 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 20 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 21 | import { useThemeColor } from "../../lib/useThemeColor"; | 21 | import { useThemeColor } from "../../lib/useThemeColor"; |
| ... | @@ -275,8 +275,6 @@ function CloudEnvironmentRowShell(props: { | ... | @@ -275,8 +275,6 @@ function CloudEnvironmentRowShell(props: { |
| 275 | readonly statusText?: string; | 275 | readonly statusText?: string; |
| 276 | readonly value: boolean; | 276 | readonly value: boolean; |
| 277 | }) { | 277 | }) { |
| 278 | const activeTrack = String(useThemeColor("--color-switch-active")); | ||
| 279 | const track = String(useThemeColor("--color-secondary-border")); | ||
| 280 | const chevron = useThemeColor("--color-chevron"); | 278 | const chevron = useThemeColor("--color-chevron"); |
| 281 | const isRetrying = | 279 | const isRetrying = |
| 282 | props.connectionState === "connecting" || props.connectionState === "reconnecting"; | 280 | props.connectionState === "connecting" || props.connectionState === "reconnecting"; |
| ... | @@ -389,11 +387,9 @@ function CloudEnvironmentRowShell(props: { | ... | @@ -389,11 +387,9 @@ function CloudEnvironmentRowShell(props: { |
| 389 | ) : null} | 387 | ) : null} |
| 390 | </StatusContainer> | 388 | </StatusContainer> |
| 391 | </View> | 389 | </View> |
| 392 | <Switch | 390 | <ThemedSwitch |
| 393 | disabled={props.disabled} | 391 | disabled={props.disabled} |
| 394 | ios_backgroundColor={track} | ||
| 395 | onValueChange={props.onValueChange} | 392 | onValueChange={props.onValueChange} |
| 396 | trackColor={{ false: track, true: activeTrack }} | ||
| 397 | value={props.value} | 393 | value={props.value} |
| 398 | /> | 394 | /> |
| 399 | </View> | 395 | </View> |
apps/mobile/src/features/files/SourceFileSurface.tsx+10-13| ... | @@ -2,14 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; | ... | @@ -2,14 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | 2 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 3 | import type { ComponentType } from "react"; | 3 | import type { ComponentType } from "react"; |
| 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | import { | 5 | import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native"; |
| 6 | FlatList, | ||
| 7 | ScrollView, | ||
| 8 | Text as NativeText, | ||
| 9 | useColorScheme, | ||
| 10 | useWindowDimensions, | ||
| 11 | View, | ||
| 12 | } from "react-native"; | ||
| 13 | 6 | ||
| 14 | import { AppText as Text } from "../../components/AppText"; | 7 | import { AppText as Text } from "../../components/AppText"; |
| 15 | import { LoadingStrip } from "../../components/LoadingStrip"; | 8 | import { LoadingStrip } from "../../components/LoadingStrip"; |
| ... | @@ -23,6 +16,7 @@ import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; | ... | @@ -23,6 +16,7 @@ import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; |
| 23 | import { cn } from "../../lib/cn"; | 16 | import { cn } from "../../lib/cn"; |
| 24 | import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; | 17 | import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; |
| 25 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | 18 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 19 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 26 | import { | 20 | import { |
| 27 | buildNativeSourceTokens, | 21 | buildNativeSourceTokens, |
| 28 | NATIVE_SOURCE_CONTENT_WIDTH, | 22 | NATIVE_SOURCE_CONTENT_WIDTH, |
| ... | @@ -115,8 +109,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { | ... | @@ -115,8 +109,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { |
| 115 | }); | 109 | }); |
| 116 | 110 | ||
| 117 | function useSourceFileModel(props: SourceFileSurfaceProps) { | 111 | function useSourceFileModel(props: SourceFileSurfaceProps) { |
| 118 | const colorScheme = useColorScheme(); | 112 | const { themeAppearance: theme } = useAppearancePreferences(); |
| 119 | const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; | ||
| 120 | const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]); | 113 | const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]); |
| 121 | const { contents: normalizedContents, lines, rowsJson } = document; | 114 | const { contents: normalizedContents, lines, rowsJson } = document; |
| 122 | const targetIndex = | 115 | const targetIndex = |
| ... | @@ -159,8 +152,9 @@ function NativeSourceFileSurface( | ... | @@ -159,8 +152,9 @@ function NativeSourceFileSurface( |
| 159 | ) { | 152 | ) { |
| 160 | const { NativeView, onRefresh } = props; | 153 | const { NativeView, onRefresh } = props; |
| 161 | const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); | 154 | const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); |
| 155 | const { themeAppearance, themeId } = useAppearancePreferences(); | ||
| 162 | const { width: viewportWidth } = useWindowDimensions(); | 156 | const { width: viewportWidth } = useWindowDimensions(); |
| 163 | const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props); | 157 | const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); |
| 164 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); | 158 | const [isPullRefreshing, setIsPullRefreshing] = useState(false); |
| 165 | const handlePullToRefresh = useCallback(async () => { | 159 | const handlePullToRefresh = useCallback(async () => { |
| 166 | if (!onRefresh) { | 160 | if (!onRefresh) { |
| ... | @@ -178,7 +172,10 @@ function NativeSourceFileSurface( | ... | @@ -178,7 +172,10 @@ function NativeSourceFileSurface( |
| 178 | () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), | 172 | () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), |
| 179 | [targetIndex], | 173 | [targetIndex], |
| 180 | ); | 174 | ); |
| 181 | const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]); | 175 | const themeJson = useMemo( |
| 176 | () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId)), | ||
| 177 | [themeAppearance, themeId], | ||
| 178 | ); | ||
| 182 | const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); | 179 | const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); |
| 183 | const contentWidth = codeWordBreak | 180 | const contentWidth = codeWordBreak |
| 184 | ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24) | 181 | ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24) |
| ... | @@ -191,7 +188,7 @@ function NativeSourceFileSurface( | ... | @@ -191,7 +188,7 @@ function NativeSourceFileSurface( |
| 191 | collapsable={false} | 188 | collapsable={false} |
| 192 | testID="source-native-code-view" | 189 | testID="source-native-code-view" |
| 193 | style={{ flex: 1 }} | 190 | style={{ flex: 1 }} |
| 194 | appearanceScheme={theme} | 191 | appearanceScheme={themeAppearance} |
| 195 | contentResetKey={props.path} | 192 | contentResetKey={props.path} |
| 196 | contentWidth={contentWidth} | 193 | contentWidth={contentWidth} |
| 197 | initialRowIndex={targetIndex ?? -1} | 194 | initialRowIndex={targetIndex ?? -1} |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+8-5| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 2 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; | 3 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 4 | import { ActivityIndicator, Platform, useColorScheme, View } from "react-native"; | 4 | import { ActivityIndicator, Platform, View } from "react-native"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | 6 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 7 | import { | 7 | import { |
| ... | @@ -35,6 +35,7 @@ import { | ... | @@ -35,6 +35,7 @@ import { |
| 35 | } from "../layout/native-mail-search-toolbar"; | 35 | } from "../layout/native-mail-search-toolbar"; |
| 36 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | 36 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 37 | import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; | 37 | import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; |
| 38 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 38 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; | 39 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; |
| 39 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | 40 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 40 | import { FileTreeBrowser } from "./FileTreeBrowser"; | 41 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| ... | @@ -242,10 +243,10 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { | ... | @@ -242,10 +243,10 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 242 | const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } = | 243 | const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } = |
| 243 | useAdaptiveWorkspaceLayout(); | 244 | useAdaptiveWorkspaceLayout(); |
| 244 | const [searchQuery, setSearchQuery] = useState(""); | 245 | const [searchQuery, setSearchQuery] = useState(""); |
| 245 | const colorScheme = useColorScheme(); | ||
| 246 | const isAndroid = Platform.OS === "android"; | 246 | const isAndroid = Platform.OS === "android"; |
| 247 | const highlightTheme = colorScheme === "dark" ? "dark" : "light"; | 247 | const { themeAppearance: highlightTheme } = useAppearancePreferences(); |
| 248 | const iconColor = String(useThemeColor("--color-icon-muted")); | 248 | const iconColor = String(useThemeColor("--color-icon-muted")); |
| 249 | const sheetSurfaceColor = String(useThemeColor("--color-sheet-solid")); | ||
| 249 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( | 250 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( |
| 250 | props.route.params, | 251 | props.route.params, |
| 251 | ); | 252 | ); |
| ... | @@ -362,10 +363,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { | ... | @@ -362,10 +363,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 362 | 363 | ||
| 363 | return ( | 364 | return ( |
| 364 | <> | 365 | <> |
| 365 | {/* Static header config (glass preset, title, contentStyle) lives in Stack.tsx. | 366 | {/* Static header config (glass preset and title) lives in Stack.tsx. The |
| 366 | Only genuinely dynamic options are set here. */} | 367 | live sheet color stays dynamic here so the FlatList can remain the |
| 368 | direct scene child for native scroll-edge sampling. */} | ||
| 367 | <NativeStackScreenOptions | 369 | <NativeStackScreenOptions |
| 368 | options={{ | 370 | options={{ |
| 371 | contentStyle: { backgroundColor: sheetSurfaceColor }, | ||
| 369 | headerShown: !isAndroid, | 372 | headerShown: !isAndroid, |
| 370 | unstable_headerSubtitle: | 373 | unstable_headerSubtitle: |
| 371 | Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined, | 374 | Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined, |
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+3-3| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; |
| 2 | import { SymbolView } from "../../components/AppSymbol"; | 2 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | import { useCallback, useMemo, useState, type ComponentProps } from "react"; | 3 | import { useCallback, useMemo, useState, type ComponentProps } from "react"; |
| 4 | import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native"; | 4 | import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; |
| 5 | import { | 5 | import { |
| 6 | Screen, | 6 | Screen, |
| 7 | ScreenStack, | 7 | ScreenStack, |
| ... | @@ -15,6 +15,7 @@ import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; | ... | @@ -15,6 +15,7 @@ import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 15 | import { useThemeColor } from "../../lib/useThemeColor"; | 15 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 16 | import { projectEnvironment } from "../../state/projects"; | 16 | import { projectEnvironment } from "../../state/projects"; |
| 17 | import { useEnvironmentQuery } from "../../state/query"; | 17 | import { useEnvironmentQuery } from "../../state/query"; |
| 18 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 18 | import { FileTreeBrowser } from "./FileTreeBrowser"; | 19 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 19 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; | 20 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 20 | 21 | ||
| ... | @@ -27,8 +28,7 @@ export function ThreadFileNavigatorPane(props: { | ... | @@ -27,8 +28,7 @@ export function ThreadFileNavigatorPane(props: { |
| 27 | readonly onSelectFile: (path: string) => void; | 28 | readonly onSelectFile: (path: string) => void; |
| 28 | }) { | 29 | }) { |
| 29 | const [searchQuery, setSearchQuery] = useState(""); | 30 | const [searchQuery, setSearchQuery] = useState(""); |
| 30 | const colorScheme = useColorScheme(); | 31 | const { themeAppearance: highlightTheme } = useAppearancePreferences(); |
| 31 | const highlightTheme = colorScheme === "dark" ? "dark" : "light"; | ||
| 32 | const iconColor = String(useThemeColor("--color-icon-muted")); | 32 | const iconColor = String(useThemeColor("--color-icon-muted")); |
| 33 | const foregroundColor = String(useThemeColor("--color-foreground")); | 33 | const foregroundColor = String(useThemeColor("--color-foreground")); |
| 34 | const sheetColor = String(useThemeColor("--color-sheet")); | 34 | const sheetColor = String(useThemeColor("--color-sheet")); |
apps/mobile/src/features/layout/workspace-pane-divider.tsx+11-12| ... | @@ -1,14 +1,8 @@ | ... | @@ -1,14 +1,8 @@ |
| 1 | import { useCallback, useMemo, useRef, useState } from "react"; | 1 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 2 | import { | 2 | import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native"; |
| 3 | Platform, | ||
| 4 | PlatformColor, | ||
| 5 | Pressable, | ||
| 6 | StyleSheet, | ||
| 7 | View, | ||
| 8 | type AccessibilityActionEvent, | ||
| 9 | } from "react-native"; | ||
| 10 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 3 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 11 | import { runOnJS } from "react-native-reanimated"; | 4 | import { runOnJS } from "react-native-reanimated"; |
| 5 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 12 | 6 | ||
| 13 | const ACCESSIBILITY_RESIZE_STEP = 24; | 7 | const ACCESSIBILITY_RESIZE_STEP = 24; |
| 14 | 8 | ||
| ... | @@ -28,6 +22,8 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { | ... | @@ -28,6 +22,8 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { |
| 28 | latestProps.current = props; | 22 | latestProps.current = props; |
| 29 | const [hovered, setHovered] = useState(false); | 23 | const [hovered, setHovered] = useState(false); |
| 30 | const [dragging, setDragging] = useState(false); | 24 | const [dragging, setDragging] = useState(false); |
| 25 | const dividerColor = useThemeColor("--color-border"); | ||
| 26 | const activeDividerColor = useThemeColor("--color-primary"); | ||
| 31 | const handleResizeStart = useCallback(() => { | 27 | const handleResizeStart = useCallback(() => { |
| 32 | setDragging(true); | 28 | setDragging(true); |
| 33 | latestProps.current.onResizeStart?.(); | 29 | latestProps.current.onResizeStart?.(); |
| ... | @@ -84,7 +80,13 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { | ... | @@ -84,7 +80,13 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { |
| 84 | onHoverIn={() => setHovered(true)} | 80 | onHoverIn={() => setHovered(true)} |
| 85 | onHoverOut={() => setHovered(false)} | 81 | onHoverOut={() => setHovered(false)} |
| 86 | > | 82 | > |
| 87 | <View style={[styles.line, (hovered || dragging) && styles.activeLine]} /> | 83 | <View |
| 84 | style={[ | ||
| 85 | styles.line, | ||
| 86 | { backgroundColor: dividerColor }, | ||
| 87 | (hovered || dragging) && [styles.activeLine, { backgroundColor: activeDividerColor }], | ||
| 88 | ]} | ||
| 89 | /> | ||
| 88 | </Pressable> | 90 | </Pressable> |
| 89 | </GestureDetector> | 91 | </GestureDetector> |
| 90 | ); | 92 | ); |
| ... | @@ -93,14 +95,11 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { | ... | @@ -93,14 +95,11 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { |
| 93 | const styles = StyleSheet.create({ | 95 | const styles = StyleSheet.create({ |
| 94 | line: { | 96 | line: { |
| 95 | alignSelf: "center", | 97 | alignSelf: "center", |
| 96 | backgroundColor: | ||
| 97 | Platform.OS === "ios" ? PlatformColor("separator") : "rgba(120, 120, 128, 0.28)", | ||
| 98 | height: "100%", | 98 | height: "100%", |
| 99 | opacity: 0.7, | 99 | opacity: 0.7, |
| 100 | width: StyleSheet.hairlineWidth, | 100 | width: StyleSheet.hairlineWidth, |
| 101 | }, | 101 | }, |
| 102 | activeLine: { | 102 | activeLine: { |
| 103 | backgroundColor: Platform.OS === "ios" ? PlatformColor("systemBlueColor") : "#0a84ff", | ||
| 104 | opacity: 1, | 103 | opacity: 1, |
| 105 | width: 2, | 104 | width: 2, |
| 106 | }, | 105 | }, |
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+4-12| ... | @@ -2,14 +2,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" | ... | @@ -2,14 +2,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" |
| 2 | import { TextInputWrapper } from "expo-paste-input"; | 2 | import { TextInputWrapper } from "expo-paste-input"; |
| 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 4 | import { useCallback, useEffect, useMemo, useState } from "react"; | 4 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 5 | import { | 5 | import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; |
| 6 | Platform, | ||
| 7 | Pressable, | ||
| 8 | ScrollView, | ||
| 9 | View, | ||
| 10 | useColorScheme, | ||
| 11 | useWindowDimensions, | ||
| 12 | } from "react-native"; | ||
| 13 | import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; | 6 | import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; |
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | import ImageViewing from "react-native-image-viewing"; | 8 | import ImageViewing from "react-native-image-viewing"; |
| ... | @@ -33,10 +26,10 @@ import { | ... | @@ -33,10 +26,10 @@ import { |
| 33 | useReviewCommentTarget, | 26 | useReviewCommentTarget, |
| 34 | } from "./reviewCommentSelection"; | 27 | } from "./reviewCommentSelection"; |
| 35 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | 28 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 29 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 36 | import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRendering"; | 30 | import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRendering"; |
| 37 | import { | 31 | import { |
| 38 | highlightReviewSelectedLines, | 32 | highlightReviewSelectedLines, |
| 39 | type ReviewDiffTheme, | ||
| 40 | type ReviewHighlightedToken, | 33 | type ReviewHighlightedToken, |
| 41 | } from "./shikiReviewHighlighter"; | 34 | } from "./shikiReviewHighlighter"; |
| 42 | 35 | ||
| ... | @@ -52,7 +45,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp | ... | @@ -52,7 +45,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp |
| 52 | const navigation = useNavigation(); | 45 | const navigation = useNavigation(); |
| 53 | const insets = useSafeAreaInsets(); | 46 | const insets = useSafeAreaInsets(); |
| 54 | const { width } = useWindowDimensions(); | 47 | const { width } = useWindowDimensions(); |
| 55 | const colorScheme = useColorScheme(); | 48 | const { themeAppearance: selectedTheme } = useAppearancePreferences(); |
| 56 | const iconTint = String(useThemeColor("--color-icon")); | 49 | const iconTint = String(useThemeColor("--color-icon")); |
| 57 | const target = useReviewCommentTarget(); | 50 | const target = useReviewCommentTarget(); |
| 58 | const { codeSurface } = useAppearanceCodeSurface(); | 51 | const { codeSurface } = useAppearanceCodeSurface(); |
| ... | @@ -72,7 +65,6 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp | ... | @@ -72,7 +65,6 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp |
| 72 | const lastLine = selectedLines[selectedLines.length - 1] ?? null; | 65 | const lastLine = selectedLines[selectedLines.length - 1] ?? null; |
| 73 | const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null; | 66 | const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null; |
| 74 | const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null; | 67 | const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null; |
| 75 | const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; | ||
| 76 | const canSubmit = | 68 | const canSubmit = |
| 77 | commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId; | 69 | commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId; |
| 78 | const selectionLabel = | 70 | const selectionLabel = |
| ... | @@ -162,7 +154,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp | ... | @@ -162,7 +154,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp |
| 162 | }, [attachments, commentText, dismissComposer, environmentId, target, threadId]); | 154 | }, [attachments, commentText, dismissComposer, environmentId, target, threadId]); |
| 163 | 155 | ||
| 164 | return ( | 156 | return ( |
| 165 | <View className="flex-1"> | 157 | <View className="flex-1 bg-sheet"> |
| 166 | <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1"> | 158 | <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1"> |
| 167 | <View | 159 | <View |
| 168 | className="flex-1 px-5" | 160 | className="flex-1 px-5" |
apps/mobile/src/features/review/ReviewSheet.tsx+13-14| ... | @@ -26,7 +26,6 @@ import { | ... | @@ -26,7 +26,6 @@ import { |
| 26 | ScrollView, | 26 | ScrollView, |
| 27 | type NativeSyntheticEvent, | 27 | type NativeSyntheticEvent, |
| 28 | StyleSheet, | 28 | StyleSheet, |
| 29 | useColorScheme, | ||
| 30 | View, | 29 | View, |
| 31 | } from "react-native"; | 30 | } from "react-native"; |
| 32 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 31 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | @@ -42,6 +41,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -42,6 +41,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 42 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; | 41 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 43 | import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; | 42 | import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; |
| 44 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; | 43 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; |
| 44 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 45 | import { | 45 | import { |
| 46 | useAdaptiveWorkspaceLayout, | 46 | useAdaptiveWorkspaceLayout, |
| 47 | useAdaptiveWorkspacePaneRole, | 47 | useAdaptiveWorkspacePaneRole, |
| ... | @@ -73,7 +73,7 @@ import { resolveReviewAvailability } from "./reviewAvailability"; | ... | @@ -73,7 +73,7 @@ import { resolveReviewAvailability } from "./reviewAvailability"; |
| 73 | import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; | 73 | import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; |
| 74 | import { buildReviewSectionMenu } from "./review-section-menu"; | 74 | import { buildReviewSectionMenu } from "./review-section-menu"; |
| 75 | import type { ReviewSectionItem } from "./reviewModel"; | 75 | import type { ReviewSectionItem } from "./reviewModel"; |
| 76 | import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; | 76 | import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal"; |
| 77 | 77 | ||
| 78 | const REVIEW_HEADER_SPACING = 0; | 78 | const REVIEW_HEADER_SPACING = 0; |
| 79 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; | 79 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| ... | @@ -97,6 +97,7 @@ function ReviewSelectionActionBar(props: { | ... | @@ -97,6 +97,7 @@ function ReviewSelectionActionBar(props: { |
| 97 | readonly onOpenComment: (() => void) | null; | 97 | readonly onOpenComment: (() => void) | null; |
| 98 | readonly onClear: () => void; | 98 | readonly onClear: () => void; |
| 99 | }) { | 99 | }) { |
| 100 | const foreground = useThemeColor("--color-primary-foreground"); | ||
| 100 | if (!props.title) { | 101 | if (!props.title) { |
| 101 | return null; | 102 | return null; |
| 102 | } | 103 | } |
| ... | @@ -106,10 +107,10 @@ function ReviewSelectionActionBar(props: { | ... | @@ -106,10 +107,10 @@ function ReviewSelectionActionBar(props: { |
| 106 | <SymbolView | 107 | <SymbolView |
| 107 | name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"} | 108 | name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"} |
| 108 | size={16} | 109 | size={16} |
| 109 | tintColor="#ffffff" | 110 | tintColor={foreground} |
| 110 | type="monochrome" | 111 | type="monochrome" |
| 111 | /> | 112 | /> |
| 112 | <Text className="text-base font-t3-bold text-white">{props.title}</Text> | 113 | <Text className="text-base font-t3-bold text-primary-foreground">{props.title}</Text> |
| 113 | </> | 114 | </> |
| 114 | ); | 115 | ); |
| 115 | 116 | ||
| ... | @@ -128,22 +129,22 @@ function ReviewSelectionActionBar(props: { | ... | @@ -128,22 +129,22 @@ function ReviewSelectionActionBar(props: { |
| 128 | > | 129 | > |
| 129 | {props.onOpenComment ? ( | 130 | {props.onOpenComment ? ( |
| 130 | <Pressable | 131 | <Pressable |
| 131 | className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-blue-600 px-5" | 132 | className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-primary px-5" |
| 132 | onPress={props.onOpenComment} | 133 | onPress={props.onOpenComment} |
| 133 | > | 134 | > |
| 134 | {content} | 135 | {content} |
| 135 | </Pressable> | 136 | </Pressable> |
| 136 | ) : ( | 137 | ) : ( |
| 137 | <View className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-blue-600 px-5"> | 138 | <View className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-primary px-5"> |
| 138 | {content} | 139 | {content} |
| 139 | </View> | 140 | </View> |
| 140 | )} | 141 | )} |
| 141 | 142 | ||
| 142 | <Pressable | 143 | <Pressable |
| 143 | className="h-12 w-12 items-center justify-center rounded-full bg-blue-600" | 144 | className="h-12 w-12 items-center justify-center rounded-full bg-primary" |
| 144 | onPress={props.onClear} | 145 | onPress={props.onClear} |
| 145 | > | 146 | > |
| 146 | <SymbolView name="xmark" size={16} tintColor="#ffffff" type="monochrome" /> | 147 | <SymbolView name="xmark" size={16} tintColor={foreground} type="monochrome" /> |
| 147 | </Pressable> | 148 | </Pressable> |
| 148 | </View> | 149 | </View> |
| 149 | ); | 150 | ); |
| ... | @@ -346,7 +347,7 @@ export function ReviewSheet(props: ReviewSheetProps) { | ... | @@ -346,7 +347,7 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 346 | const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); | 347 | const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 347 | const navigation = useNavigation(); | 348 | const navigation = useNavigation(); |
| 348 | const insets = useSafeAreaInsets(); | 349 | const insets = useSafeAreaInsets(); |
| 349 | const colorScheme = useColorScheme(); | 350 | const { themeAppearance: selectedTheme } = useAppearancePreferences(); |
| 350 | const headerIcon = String(useThemeColor("--color-icon")); | 351 | const headerIcon = String(useThemeColor("--color-icon")); |
| 351 | const { environmentId, threadId } = props.route.params; | 352 | const { environmentId, threadId } = props.route.params; |
| 352 | const environment = useEnvironmentPresentation(environmentId); | 353 | const environment = useEnvironmentPresentation(environmentId); |
| ... | @@ -371,7 +372,6 @@ export function ReviewSheet(props: ReviewSheetProps) { | ... | @@ -371,7 +372,6 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 371 | // selected thread (it always does when reached from the thread's toolbar). | 372 | // selected thread (it always does when reached from the thread's toolbar). |
| 372 | const gitMenuAvailable = | 373 | const gitMenuAvailable = |
| 373 | selectedThread !== null && String(selectedThread.id) === String(threadId); | 374 | selectedThread !== null && String(selectedThread.id) === String(threadId); |
| 374 | const selectedTheme = colorScheme === "dark" ? "dark" : "light"; | ||
| 375 | // With a solid (non-overlay) header the content lays out below the header | 375 | // With a solid (non-overlay) header the content lays out below the header |
| 376 | // natively, so no manual top inset is needed. (Android renders its own | 376 | // natively, so no manual top inset is needed. (Android renders its own |
| 377 | // in-flow AndroidScreenHeader, so it needs no inset either.) | 377 | // in-flow AndroidScreenHeader, so it needs no inset either.) |
| ... | @@ -436,7 +436,6 @@ export function ReviewSheet(props: ReviewSheetProps) { | ... | @@ -436,7 +436,6 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 436 | sectionId: selectedSection?.id ?? null, | 436 | sectionId: selectedSection?.id ?? null, |
| 437 | diff: selectedSection?.diff, | 437 | diff: selectedSection?.diff, |
| 438 | data: nativeReviewDiffData, | 438 | data: nativeReviewDiffData, |
| 439 | scheme: selectedTheme, | ||
| 440 | collapsedFileIds, | 439 | collapsedFileIds, |
| 441 | viewedFileIds, | 440 | viewedFileIds, |
| 442 | selectedRowIds: commentSelection.selectedRowIds, | 441 | selectedRowIds: commentSelection.selectedRowIds, |
| ... | @@ -444,7 +443,7 @@ export function ReviewSheet(props: ReviewSheetProps) { | ... | @@ -444,7 +443,7 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 444 | }); | 443 | }); |
| 445 | const showcaseReviewKey = | 444 | const showcaseReviewKey = |
| 446 | SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection | 445 | SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection |
| 447 | ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}` | 446 | ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}:${nativeBridge.themeId}` |
| 448 | : null; | 447 | : null; |
| 449 | const handleNativeDebug = useCallback( | 448 | const handleNativeDebug = useCallback( |
| 450 | (event: NativeSyntheticEvent<Record<string, unknown>>) => { | 449 | (event: NativeSyntheticEvent<Record<string, unknown>>) => { |
| ... | @@ -457,9 +456,9 @@ export function ReviewSheet(props: ReviewSheetProps) { | ... | @@ -457,9 +456,9 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 457 | return; | 456 | return; |
| 458 | } | 457 | } |
| 459 | showcasedReviewDrawRef.current = showcaseReviewKey; | 458 | showcasedReviewDrawRef.current = showcaseReviewKey; |
| 460 | markNativeShowcaseReady("review"); | 459 | reportShowcaseSceneRendered({ scene: "review", themeId: nativeBridge.themeId }); |
| 461 | }, | 460 | }, |
| 462 | [nativeBridge.onDebug, showcaseReviewKey], | 461 | [nativeBridge.onDebug, nativeBridge.themeId, showcaseReviewKey], |
| 463 | ); | 462 | ); |
| 464 | 463 | ||
| 465 | const handleSelectFile = useCallback( | 464 | const handleSelectFile = useCallback( |
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+25| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { MOBILE_THEME_IDS } from "../../lib/mobileTheme"; | ||
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 5 | createNativeReviewDiffTheme, | ||
| 4 | getCachedNativeReviewDiffData, | 6 | getCachedNativeReviewDiffData, |
| 5 | type BuildNativeReviewDiffDataInput, | 7 | type BuildNativeReviewDiffDataInput, |
| 6 | } from "./nativeReviewDiffAdapter"; | 8 | } from "./nativeReviewDiffAdapter"; |
| ... | @@ -54,3 +56,26 @@ describe("getCachedNativeReviewDiffData", () => { | ... | @@ -54,3 +56,26 @@ describe("getCachedNativeReviewDiffData", () => { |
| 54 | expect(changed).not.toBe(first); | 56 | expect(changed).not.toBe(first); |
| 55 | }); | 57 | }); |
| 56 | }); | 58 | }); |
| 59 | |||
| 60 | describe("createNativeReviewDiffTheme", () => { | ||
| 61 | it("serializes every native color as cross-platform opaque hex", () => { | ||
| 62 | for (const themeId of MOBILE_THEME_IDS) { | ||
| 63 | for (const appearance of ["light", "dark"] as const) { | ||
| 64 | const theme = createNativeReviewDiffTheme(appearance, themeId); | ||
| 65 | for (const color of Object.values(theme)) { | ||
| 66 | expect(color, `${themeId}/${appearance}`).toMatch(/^#[\da-f]{6}$/i); | ||
| 67 | } | ||
| 68 | } | ||
| 69 | } | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("uses the selected app palette for native code surfaces", () => { | ||
| 73 | const standard = createNativeReviewDiffTheme("dark", "t3-code"); | ||
| 74 | const iris = createNativeReviewDiffTheme("dark", "iris"); | ||
| 75 | |||
| 76 | expect(iris.background).not.toBe(standard.background); | ||
| 77 | expect(iris.hunkText).not.toBe(standard.hunkText); | ||
| 78 | expect(iris.addBar).toBe(standard.addBar); | ||
| 79 | expect(iris.deleteBar).toBe(standard.deleteBar); | ||
| 80 | }); | ||
| 81 | }); |
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+49-17| ... | @@ -8,7 +8,12 @@ import { pipe } from "effect/Function"; | ... | @@ -8,7 +8,12 @@ import { pipe } from "effect/Function"; |
| 8 | import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; | 8 | import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; |
| 9 | import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; | 9 | import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; |
| 10 | import { MOBILE_CODE_SURFACE } from "../../lib/typography"; | 10 | import { MOBILE_CODE_SURFACE } from "../../lib/typography"; |
| 11 | import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; | 11 | import { |
| 12 | DEFAULT_MOBILE_THEME_ID, | ||
| 13 | getMobileThemeVariables, | ||
| 14 | type MobileThemeId, | ||
| 15 | } from "../../lib/mobileTheme"; | ||
| 16 | import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; | ||
| 12 | import { computeWordAltDiffRanges } from "./reviewWordDiffs"; | 17 | import { computeWordAltDiffRanges } from "./reviewWordDiffs"; |
| 13 | import { | 18 | import { |
| 14 | getReviewFilePreviewState, | 19 | getReviewFilePreviewState, |
| ... | @@ -20,6 +25,9 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; | ... | @@ -20,6 +25,9 @@ import type { ReviewInlineComment } from "./reviewCommentSelection"; |
| 20 | 25 | ||
| 21 | const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; | 26 | const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; |
| 22 | const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; | 27 | const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; |
| 28 | const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; | ||
| 29 | const NATIVE_RGBA_COLOR = | ||
| 30 | /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; | ||
| 23 | 31 | ||
| 24 | export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; | 32 | export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; |
| 25 | export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; | 33 | export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; |
| ... | @@ -28,6 +36,23 @@ export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( | ... | @@ -28,6 +36,23 @@ export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( |
| 28 | resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), | 36 | resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), |
| 29 | ); | 37 | ); |
| 30 | 38 | ||
| 39 | function opaqueNativeHexColor(color: string, background: string): string { | ||
| 40 | const hex = NATIVE_HEX_COLOR.exec(color); | ||
| 41 | if (hex) return color; | ||
| 42 | |||
| 43 | const rgba = NATIVE_RGBA_COLOR.exec(color); | ||
| 44 | const backgroundHex = NATIVE_HEX_COLOR.exec(background); | ||
| 45 | if (!rgba || !backgroundHex) return background; | ||
| 46 | |||
| 47 | const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4]))); | ||
| 48 | const channels = [1, 2, 3].map((index) => { | ||
| 49 | const foreground = Number(rgba[index]); | ||
| 50 | const behind = Number.parseInt(backgroundHex[index], 16); | ||
| 51 | return Math.round(foreground * alpha + behind * (1 - alpha)); | ||
| 52 | }); | ||
| 53 | return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; | ||
| 54 | } | ||
| 55 | |||
| 31 | export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { | 56 | export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { |
| 32 | return { | 57 | return { |
| 33 | rowHeight: codeSurface.rowHeight, | 58 | rowHeight: codeSurface.rowHeight, |
| ... | @@ -112,21 +137,28 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment | ... | @@ -112,21 +137,28 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment |
| 112 | 137 | ||
| 113 | export function createNativeReviewDiffTheme( | 138 | export function createNativeReviewDiffTheme( |
| 114 | scheme: TerminalAppearanceScheme, | 139 | scheme: TerminalAppearanceScheme, |
| 140 | themeId: MobileThemeId = DEFAULT_MOBILE_THEME_ID, | ||
| 115 | ): NativeReviewDiffTheme { | 141 | ): NativeReviewDiffTheme { |
| 116 | const terminalTheme = getPierreTerminalTheme(scheme); | 142 | const terminalTheme = getMobileTerminalTheme(themeId, scheme); |
| 117 | const [, terminalRed, , , terminalBlue] = terminalTheme.palette; | 143 | const appTheme = getMobileThemeVariables(themeId, scheme); |
| 144 | const [, terminalRed] = terminalTheme.palette; | ||
| 145 | // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB. | ||
| 146 | // Flatten translucent app tokens onto the code surface so both native | ||
| 147 | // implementations receive the one unambiguous shared format. | ||
| 148 | const background = opaqueNativeHexColor(appTheme["--color-sheet"], appTheme["--color-screen"]); | ||
| 149 | const nativeColor = (color: string) => opaqueNativeHexColor(color, background); | ||
| 118 | 150 | ||
| 119 | if (scheme === "dark") { | 151 | if (scheme === "dark") { |
| 120 | return { | 152 | return { |
| 121 | // Match the app surface (--color-sheet) so code views blend with the rest of | 153 | // Match the app surface (--color-sheet) so code views blend with the rest of |
| 122 | // the app instead of using a distinct code-editor background. | 154 | // the app instead of using a distinct code-editor background. |
| 123 | background: "#0e0e0e", | 155 | background, |
| 124 | text: terminalTheme.foreground, | 156 | text: nativeColor(appTheme["--color-md-code-text"]), |
| 125 | mutedText: terminalTheme.mutedForeground, | 157 | mutedText: nativeColor(appTheme["--color-foreground-muted"]), |
| 126 | headerBackground: "#0e0e0e", | 158 | headerBackground: background, |
| 127 | border: terminalTheme.border, | 159 | border: nativeColor(appTheme["--color-border"]), |
| 128 | hunkBackground: "#071f28", | 160 | hunkBackground: nativeColor(appTheme["--color-subtle-strong"]), |
| 129 | hunkText: terminalBlue ?? "#009fff", | 161 | hunkText: nativeColor(appTheme["--color-primary"]), |
| 130 | addBackground: "#0d2f28", | 162 | addBackground: "#0d2f28", |
| 131 | deleteBackground: "#391415", | 163 | deleteBackground: "#391415", |
| 132 | addBar: "#00cab1", | 164 | addBar: "#00cab1", |
| ... | @@ -139,13 +171,13 @@ export function createNativeReviewDiffTheme( | ... | @@ -139,13 +171,13 @@ export function createNativeReviewDiffTheme( |
| 139 | return { | 171 | return { |
| 140 | // Match the app surface (--color-sheet) so code views blend with the rest of the | 172 | // Match the app surface (--color-sheet) so code views blend with the rest of the |
| 141 | // app instead of using a distinct code-editor background. | 173 | // app instead of using a distinct code-editor background. |
| 142 | background: "#f2f2f7", | 174 | background, |
| 143 | text: "#070707", | 175 | text: nativeColor(appTheme["--color-md-code-text"]), |
| 144 | mutedText: terminalTheme.mutedForeground, | 176 | mutedText: nativeColor(appTheme["--color-foreground-muted"]), |
| 145 | headerBackground: "#f2f2f7", | 177 | headerBackground: background, |
| 146 | border: terminalTheme.border, | 178 | border: nativeColor(appTheme["--color-border"]), |
| 147 | hunkBackground: "#e0f2ff", | 179 | hunkBackground: nativeColor(appTheme["--color-subtle-strong"]), |
| 148 | hunkText: terminalBlue ?? "#009fff", | 180 | hunkText: nativeColor(appTheme["--color-primary"]), |
| 149 | addBackground: "#e5f8f5", | 181 | addBackground: "#e5f8f5", |
| 150 | deleteBackground: "#ffe6e7", | 182 | deleteBackground: "#ffe6e7", |
| 151 | addBar: "#00cab1", | 183 | addBar: "#00cab1", |
apps/mobile/src/features/review/useNativeReviewDiffBridge.ts+4-4| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | import { useCallback, useMemo, useState } from "react"; | 1 | import { useCallback, useMemo, useState } from "react"; |
| 2 | import type { NativeSyntheticEvent } from "react-native"; | 2 | import type { NativeSyntheticEvent } from "react-native"; |
| 3 | 3 | ||
| 4 | import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter"; | ||
| 5 | import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter"; | 4 | import { createNativeReviewDiffTheme, type NativeReviewDiffData } from "./nativeReviewDiffAdapter"; |
| 6 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | 5 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 6 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 7 | import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting"; | 7 | import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting"; |
| 8 | import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; | 8 | import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; |
| 9 | 9 | ||
| ... | @@ -14,7 +14,6 @@ export function useNativeReviewDiffBridge(input: { | ... | @@ -14,7 +14,6 @@ export function useNativeReviewDiffBridge(input: { |
| 14 | readonly sectionId: string | null; | 14 | readonly sectionId: string | null; |
| 15 | readonly diff: string | null | undefined; | 15 | readonly diff: string | null | undefined; |
| 16 | readonly data: NativeReviewDiffData; | 16 | readonly data: NativeReviewDiffData; |
| 17 | readonly scheme: NativeReviewDiffHighlightScheme; | ||
| 18 | readonly collapsedFileIds: ReadonlyArray<string>; | 17 | readonly collapsedFileIds: ReadonlyArray<string>; |
| 19 | readonly viewedFileIds: ReadonlyArray<string>; | 18 | readonly viewedFileIds: ReadonlyArray<string>; |
| 20 | readonly selectedRowIds: ReadonlyArray<string>; | 19 | readonly selectedRowIds: ReadonlyArray<string>; |
| ... | @@ -25,18 +24,18 @@ export function useNativeReviewDiffBridge(input: { | ... | @@ -25,18 +24,18 @@ export function useNativeReviewDiffBridge(input: { |
| 25 | collapsedFileIds, | 24 | collapsedFileIds, |
| 26 | data, | 25 | data, |
| 27 | diff, | 26 | diff, |
| 28 | scheme, | ||
| 29 | sectionId, | 27 | sectionId, |
| 30 | selectedRowIds, | 28 | selectedRowIds, |
| 31 | threadKey, | 29 | threadKey, |
| 32 | viewedFileIds, | 30 | viewedFileIds, |
| 33 | } = input; | 31 | } = input; |
| 34 | const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); | 32 | const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); |
| 33 | const { themeAppearance: scheme, themeId } = useAppearancePreferences(); | ||
| 35 | const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>( | 34 | const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>( |
| 36 | () => new Set(), | 35 | () => new Set(), |
| 37 | ); | 36 | ); |
| 38 | 37 | ||
| 39 | const theme = useMemo(() => createNativeReviewDiffTheme(scheme), [scheme]); | 38 | const theme = useMemo(() => createNativeReviewDiffTheme(scheme, themeId), [scheme, themeId]); |
| 40 | const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); | 39 | const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); |
| 41 | const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); | 40 | const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); |
| 42 | const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]); | 41 | const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]); |
| ... | @@ -106,6 +105,7 @@ export function useNativeReviewDiffBridge(input: { | ... | @@ -106,6 +105,7 @@ export function useNativeReviewDiffBridge(input: { |
| 106 | ); | 105 | ); |
| 107 | 106 | ||
| 108 | return { | 107 | return { |
| 108 | themeId, | ||
| 109 | theme, | 109 | theme, |
| 110 | rowsJson, | 110 | rowsJson, |
| 111 | collapsedFileIdsJson, | 111 | collapsedFileIdsJson, |
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+2| ... | @@ -7,6 +7,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; | ... | @@ -7,6 +7,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 7 | import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; | 7 | import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; |
| 8 | import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; | 8 | import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; |
| 9 | import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; | 9 | import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; |
| 10 | import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; | ||
| 10 | 11 | ||
| 11 | export function SettingsAppearanceRouteScreen() { | 12 | export function SettingsAppearanceRouteScreen() { |
| 12 | const navigation = useNavigation(); | 13 | const navigation = useNavigation(); |
| ... | @@ -29,6 +30,7 @@ export function SettingsAppearanceRouteScreen() { | ... | @@ -29,6 +30,7 @@ export function SettingsAppearanceRouteScreen() { |
| 29 | paddingBottom: Math.max(insets.bottom, 18) + 18, | 30 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| 30 | }} | 31 | }} |
| 31 | > | 32 | > |
| 33 | <ThemeAppearanceSection /> | ||
| 32 | <TextAppearanceSection /> | 34 | <TextAppearanceSection /> |
| 33 | <TerminalAppearanceSection /> | 35 | <TerminalAppearanceSection /> |
| 34 | <CodeAppearanceSection /> | 36 | <CodeAppearanceSection /> |
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+1-8| ... | @@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac | ... | @@ -2,7 +2,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac |
| 2 | import { useNavigation } from "@react-navigation/native"; | 2 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import { SymbolView } from "../../components/AppSymbol"; | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | import { useCallback, useEffect, useState } from "react"; | 5 | import { useCallback, useState } from "react"; |
| 6 | import { Platform, ScrollView, View } from "react-native"; | 6 | import { Platform, ScrollView, View } from "react-native"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 8 | ||
| ... | @@ -20,7 +20,6 @@ import { | ... | @@ -20,7 +20,6 @@ import { |
| 20 | SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, | 20 | SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, |
| 21 | SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS, | 21 | SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS, |
| 22 | } from "../showcase/showcaseEnvironmentRows"; | 22 | } from "../showcase/showcaseEnvironmentRows"; |
| 23 | import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; | ||
| 24 | 23 | ||
| 25 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; | 24 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| 26 | 25 | ||
| ... | @@ -48,12 +47,6 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -48,12 +47,6 @@ export function SettingsEnvironmentsRouteScreen() { |
| 48 | const accentColor = useThemeColor("--color-icon-muted"); | 47 | const accentColor = useThemeColor("--color-icon-muted"); |
| 49 | const headerIconColor = useThemeColor("--color-icon"); | 48 | const headerIconColor = useThemeColor("--color-icon"); |
| 50 | 49 | ||
| 51 | useEffect(() => { | ||
| 52 | if (!SHOWCASE_ENABLED) return; | ||
| 53 | const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500); | ||
| 54 | return () => clearTimeout(timer); | ||
| 55 | }, []); | ||
| 56 | |||
| 57 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | 50 | const handleToggle = useCallback((environmentId: EnvironmentId) => { |
| 58 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | 51 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); |
| 59 | }, []); | 52 | }, []); |
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+101-19| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { createContext, use, useCallback, useEffect, useMemo, type ReactNode } from "react"; | 1 | import { createContext, use, useCallback, useLayoutEffect, useMemo, type ReactNode } from "react"; |
| 2 | import { useColorScheme } from "react-native"; | ||
| 2 | 3 | ||
| 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 4 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | 5 | import { AsyncResult } from "effect/unstable/reactivity"; |
| ... | @@ -9,16 +10,37 @@ import { | ... | @@ -9,16 +10,37 @@ import { |
| 9 | resolveAppearance, | 10 | resolveAppearance, |
| 10 | resolveAppearancePreferences, | 11 | resolveAppearancePreferences, |
| 11 | resolveTextScaleVariables, | 12 | resolveTextScaleVariables, |
| 12 | type AppearancePreferences, | ||
| 13 | type ResolvedAppearance, | 13 | type ResolvedAppearance, |
| 14 | } from "../../../lib/appearancePreferences"; | 14 | } from "../../../lib/appearancePreferences"; |
| 15 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; | 15 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; |
| 16 | import type { Preferences } from "../../../persistence/mobile-preferences"; | ||
| 17 | import { | ||
| 18 | createMobileThemePairPatch, | ||
| 19 | createMobileThemeSelectionPatch, | ||
| 20 | getMobileThemeVariables, | ||
| 21 | normalizeMobileThemeMode, | ||
| 22 | resolveMobileThemeIds, | ||
| 23 | type MobileThemeAppearance, | ||
| 24 | type MobileThemeId, | ||
| 25 | type MobileThemeIds, | ||
| 26 | type MobileThemeMode, | ||
| 27 | } from "../../../lib/mobileTheme"; | ||
| 16 | import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; | 28 | import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; |
| 17 | 29 | ||
| 18 | interface AppearancePreferencesContextValue { | 30 | interface AppearancePreferencesContextValue { |
| 19 | /** Effective values with base-size derivation applied. Use this for rendering. */ | 31 | /** Effective values with base-size derivation applied. Use this for rendering. */ |
| 20 | readonly appearance: ResolvedAppearance; | 32 | readonly appearance: ResolvedAppearance; |
| 33 | readonly themeId: MobileThemeId; | ||
| 34 | readonly themeIds: MobileThemeIds; | ||
| 35 | readonly themeMode: MobileThemeMode; | ||
| 36 | readonly themeAppearance: MobileThemeAppearance; | ||
| 21 | readonly isReady: boolean; | 37 | readonly isReady: boolean; |
| 38 | readonly setThemeIdForAppearance: ( | ||
| 39 | appearance: MobileThemeAppearance, | ||
| 40 | value: MobileThemeId, | ||
| 41 | ) => void; | ||
| 42 | readonly setThemeIdForBothAppearances: (value: MobileThemeId) => void; | ||
| 43 | readonly setThemeMode: (value: MobileThemeMode) => void; | ||
| 22 | readonly setBaseFontSize: (value: number) => void; | 44 | readonly setBaseFontSize: (value: number) => void; |
| 23 | /** Pass null to clear the override and follow the base font size. */ | 45 | /** Pass null to clear the override and follow the base font size. */ |
| 24 | readonly setTerminalFontSize: (value: number | null) => void; | 46 | readonly setTerminalFontSize: (value: number | null) => void; |
| ... | @@ -30,46 +52,85 @@ interface AppearancePreferencesContextValue { | ... | @@ -30,46 +52,85 @@ interface AppearancePreferencesContextValue { |
| 30 | const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null); | 52 | const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null); |
| 31 | 53 | ||
| 32 | /** | 54 | /** |
| 33 | * Injects the scaled `--text-*` variables into Uniwind so every | 55 | * Injects palette and text-scale variables into both adaptive stylesheets. |
| 34 | * className-based text size (`text-sm`, `text-base`, ...) re-resolves live. | 56 | * Updating the active sheet last lets the visible app settle in one pass. |
| 35 | * Updates the current theme last so the active stylesheet settles correctly. | ||
| 36 | */ | 57 | */ |
| 37 | function applyTextScaleVariables(baseFontSize: number) { | 58 | function applyAppearanceVariables(baseFontSize: number, themeIds: MobileThemeIds) { |
| 38 | const variables = resolveTextScaleVariables(baseFontSize); | 59 | const textVariables = resolveTextScaleVariables(baseFontSize); |
| 39 | const currentTheme = Uniwind.currentTheme; | 60 | const currentTheme = Uniwind.currentTheme; |
| 61 | const activeAppearance = | ||
| 62 | currentTheme === "light" || currentTheme === "dark" ? currentTheme : null; | ||
| 40 | 63 | ||
| 41 | for (const theme of ["light", "dark"] as const) { | 64 | for (const theme of ["light", "dark"] as const) { |
| 42 | if (theme !== currentTheme) { | 65 | const variables = { ...getMobileThemeVariables(themeIds[theme], theme), ...textVariables }; |
| 66 | if (theme !== activeAppearance) { | ||
| 43 | Uniwind.updateCSSVariables(theme, variables); | 67 | Uniwind.updateCSSVariables(theme, variables); |
| 44 | } | 68 | } |
| 45 | } | 69 | } |
| 46 | Uniwind.updateCSSVariables(currentTheme, variables); | 70 | if (activeAppearance !== null) { |
| 71 | Uniwind.updateCSSVariables(activeAppearance, { | ||
| 72 | ...getMobileThemeVariables(themeIds[activeAppearance], activeAppearance), | ||
| 73 | ...textVariables, | ||
| 74 | }); | ||
| 75 | } | ||
| 47 | } | 76 | } |
| 48 | 77 | ||
| 49 | export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { | 78 | export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { |
| 50 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | 79 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 51 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | 80 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 81 | const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light"; | ||
| 82 | const storedPreferences = AsyncResult.isSuccess(preferencesResult) | ||
| 83 | ? preferencesResult.value | ||
| 84 | : null; | ||
| 52 | const preferences = useMemo( | 85 | const preferences = useMemo( |
| 53 | () => | 86 | () => resolveAppearancePreferences(storedPreferences), |
| 54 | resolveAppearancePreferences( | 87 | [storedPreferences], |
| 55 | AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null, | 88 | ); |
| 56 | ), | 89 | const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode); |
| 57 | [preferencesResult], | 90 | const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode; |
| 91 | const themeIds = useMemo( | ||
| 92 | () => resolveMobileThemeIds(storedPreferences ?? {}), | ||
| 93 | [storedPreferences], | ||
| 58 | ); | 94 | ); |
| 95 | const themeId = themeIds[themeAppearance]; | ||
| 59 | const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; | 96 | const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; |
| 60 | 97 | ||
| 61 | useEffect(() => { | 98 | useLayoutEffect(() => { |
| 62 | applyTextScaleVariables(preferences.baseFontSize); | 99 | applyAppearanceVariables(preferences.baseFontSize, themeIds); |
| 100 | Uniwind.setTheme(themeMode); | ||
| 63 | cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); | 101 | cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); |
| 64 | }, [preferences]); | 102 | }, [preferences, themeIds, themeMode]); |
| 65 | 103 | ||
| 66 | const updatePreferences = useCallback( | 104 | const updatePreferences = useCallback( |
| 67 | (patch: Partial<AppearancePreferences>) => { | 105 | (patch: Partial<Preferences>) => { |
| 68 | savePreferences(patch); | 106 | savePreferences(patch); |
| 69 | }, | 107 | }, |
| 70 | [savePreferences], | 108 | [savePreferences], |
| 71 | ); | 109 | ); |
| 72 | 110 | ||
| 111 | const setThemeIdForAppearance = useCallback( | ||
| 112 | (appearance: MobileThemeAppearance, value: MobileThemeId) => { | ||
| 113 | updatePreferences( | ||
| 114 | createMobileThemeSelectionPatch(themeIds, themeAppearance, appearance, value), | ||
| 115 | ); | ||
| 116 | }, | ||
| 117 | [themeAppearance, themeIds, updatePreferences], | ||
| 118 | ); | ||
| 119 | |||
| 120 | const setThemeIdForBothAppearances = useCallback( | ||
| 121 | (value: MobileThemeId) => { | ||
| 122 | updatePreferences(createMobileThemePairPatch(value)); | ||
| 123 | }, | ||
| 124 | [updatePreferences], | ||
| 125 | ); | ||
| 126 | |||
| 127 | const setThemeMode = useCallback( | ||
| 128 | (value: MobileThemeMode) => { | ||
| 129 | updatePreferences({ themeMode: value }); | ||
| 130 | }, | ||
| 131 | [updatePreferences], | ||
| 132 | ); | ||
| 133 | |||
| 73 | const setBaseFontSize = useCallback( | 134 | const setBaseFontSize = useCallback( |
| 74 | (value: number) => { | 135 | (value: number) => { |
| 75 | updatePreferences({ baseFontSize: value }); | 136 | updatePreferences({ baseFontSize: value }); |
| ... | @@ -101,13 +162,34 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN | ... | @@ -101,13 +162,34 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 101 | const value = useMemo( | 162 | const value = useMemo( |
| 102 | (): AppearancePreferencesContextValue => ({ | 163 | (): AppearancePreferencesContextValue => ({ |
| 103 | appearance: resolveAppearance(preferences), | 164 | appearance: resolveAppearance(preferences), |
| 165 | themeId, | ||
| 166 | themeIds, | ||
| 167 | themeMode, | ||
| 168 | themeAppearance, | ||
| 104 | isReady, | 169 | isReady, |
| 170 | setThemeIdForAppearance, | ||
| 171 | setThemeIdForBothAppearances, | ||
| 172 | setThemeMode, | ||
| 105 | setBaseFontSize, | 173 | setBaseFontSize, |
| 106 | setTerminalFontSize, | 174 | setTerminalFontSize, |
| 107 | setCodeFontSize, | 175 | setCodeFontSize, |
| 108 | setCodeWordBreak, | 176 | setCodeWordBreak, |
| 109 | }), | 177 | }), |
| 110 | [preferences, isReady, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak], | 178 | [ |
| 179 | preferences, | ||
| 180 | themeId, | ||
| 181 | themeIds, | ||
| 182 | themeMode, | ||
| 183 | themeAppearance, | ||
| 184 | isReady, | ||
| 185 | setThemeIdForAppearance, | ||
| 186 | setThemeIdForBothAppearances, | ||
| 187 | setThemeMode, | ||
| 188 | setBaseFontSize, | ||
| 189 | setTerminalFontSize, | ||
| 190 | setCodeFontSize, | ||
| 191 | setCodeWordBreak, | ||
| 192 | ], | ||
| 111 | ); | 193 | ); |
| 112 | 194 | ||
| 113 | return ( | 195 | return ( |
apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx+5-11| ... | @@ -1,11 +1,4 @@ | ... | @@ -1,11 +1,4 @@ |
| 1 | import { | 1 | import { Platform, ScrollView, type StyleProp, type TextStyle, View } from "react-native"; |
| 2 | Platform, | ||
| 3 | ScrollView, | ||
| 4 | type StyleProp, | ||
| 5 | type TextStyle, | ||
| 6 | View, | ||
| 7 | useColorScheme, | ||
| 8 | } from "react-native"; | ||
| 9 | 2 | ||
| 10 | import { AppText as Text } from "../../../../components/AppText"; | 3 | import { AppText as Text } from "../../../../components/AppText"; |
| 11 | import { | 4 | import { |
| ... | @@ -13,7 +6,8 @@ import { | ... | @@ -13,7 +6,8 @@ import { |
| 13 | resolveMobileCodeSurface, | 6 | resolveMobileCodeSurface, |
| 14 | } from "../../../../lib/appearancePreferences"; | 7 | } from "../../../../lib/appearancePreferences"; |
| 15 | import { useThemeColor } from "../../../../lib/useThemeColor"; | 8 | import { useThemeColor } from "../../../../lib/useThemeColor"; |
| 16 | import { getPierreTerminalTheme } from "../../../terminal/terminalTheme"; | 9 | import { getMobileTerminalTheme } from "../../../terminal/terminalTheme"; |
| 10 | import { useAppearancePreferences } from "../AppearancePreferencesProvider"; | ||
| 17 | 11 | ||
| 18 | const CODE_FONT_FAMILY = Platform.select({ | 12 | const CODE_FONT_FAMILY = Platform.select({ |
| 19 | ios: "ui-monospace", | 13 | ios: "ui-monospace", |
| ... | @@ -53,8 +47,8 @@ export function TextAppearancePreview(props: { readonly fontSize: number }) { | ... | @@ -53,8 +47,8 @@ export function TextAppearancePreview(props: { readonly fontSize: number }) { |
| 53 | * on the shared card background so it reads like the other previews. | 47 | * on the shared card background so it reads like the other previews. |
| 54 | */ | 48 | */ |
| 55 | export function TerminalAppearancePreview(props: { readonly fontSize: number }) { | 49 | export function TerminalAppearancePreview(props: { readonly fontSize: number }) { |
| 56 | const scheme = useColorScheme() === "light" ? "light" : "dark"; | 50 | const { themeAppearance: scheme, themeId } = useAppearancePreferences(); |
| 57 | const theme = getPierreTerminalTheme(scheme); | 51 | const theme = getMobileTerminalTheme(themeId, scheme); |
| 58 | const lineHeight = Math.round(props.fontSize * 1.6); | 52 | const lineHeight = Math.round(props.fontSize * 1.6); |
| 59 | const lineStyle = { | 53 | const lineStyle = { |
| 60 | fontFamily: "Menlo", | 54 | fontFamily: "Menlo", |
apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx created+365| ... | @@ -0,0 +1,365 @@ | ||
| 1 | import { memo, useId } from "react"; | ||
| 2 | import { Pressable, View } from "react-native"; | ||
| 3 | import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; | ||
| 4 | |||
| 5 | import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; | ||
| 6 | |||
| 7 | import { SymbolView } from "../../../../components/AppSymbol"; | ||
| 8 | import { AppText as Text } from "../../../../components/AppText"; | ||
| 9 | import { | ||
| 10 | getMobileThemeVariables, | ||
| 11 | getMobileThemePreviewColors, | ||
| 12 | MOBILE_THEME_OPTIONS, | ||
| 13 | type MobileThemeAppearance, | ||
| 14 | type MobileThemeId, | ||
| 15 | type MobileThemeIds, | ||
| 16 | type MobileThemeMode, | ||
| 17 | type MobileThemeVariables, | ||
| 18 | } from "../../../../lib/mobileTheme"; | ||
| 19 | import { useThemeColor } from "../../../../lib/useThemeColor"; | ||
| 20 | import { useAppearancePreferences } from "../AppearancePreferencesProvider"; | ||
| 21 | |||
| 22 | const APPEARANCE_MODES: ReadonlyArray<{ | ||
| 23 | readonly id: MobileThemeMode; | ||
| 24 | readonly label: string; | ||
| 25 | }> = [ | ||
| 26 | { id: "system", label: "System" }, | ||
| 27 | { id: "light", label: "Light" }, | ||
| 28 | { id: "dark", label: "Dark" }, | ||
| 29 | ]; | ||
| 30 | |||
| 31 | const PreviewOrb = memo(function PreviewOrb(props: { | ||
| 32 | readonly appearance: MobileThemeAppearance; | ||
| 33 | readonly compact?: boolean; | ||
| 34 | readonly themeId: MobileThemeId; | ||
| 35 | }) { | ||
| 36 | const idPrefix = useId().replaceAll(":", ""); | ||
| 37 | const accentGradientId = `${idPrefix}-accent-glow`; | ||
| 38 | const actionGradientId = `${idPrefix}-action-glow`; | ||
| 39 | const colors = getMobileThemePreviewColors(props.themeId, props.appearance); | ||
| 40 | const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance]; | ||
| 41 | const accentRadius = Math.hypot( | ||
| 42 | Math.max(spec.accent.center[0], 1 - spec.accent.center[0]), | ||
| 43 | Math.max(spec.accent.center[1], 1 - spec.accent.center[1]), | ||
| 44 | ); | ||
| 45 | const actionRadius = Math.hypot( | ||
| 46 | Math.max(spec.action.center[0], 1 - spec.action.center[0]), | ||
| 47 | Math.max(spec.action.center[1], 1 - spec.action.center[1]), | ||
| 48 | ); | ||
| 49 | const position = (value: number) => `${value * 100}%`; | ||
| 50 | const radius = (value: number) => `${value * 100}%`; | ||
| 51 | |||
| 52 | return ( | ||
| 53 | <View | ||
| 54 | className={`${props.compact ? "size-14" : "size-16"} overflow-hidden rounded-full border`} | ||
| 55 | style={{ | ||
| 56 | borderColor: | ||
| 57 | props.appearance === "dark" ? "rgba(255, 255, 255, 0.14)" : "rgba(0, 0, 0, 0.1)", | ||
| 58 | }} | ||
| 59 | > | ||
| 60 | <Svg accessibilityElementsHidden height="100%" viewBox="0 0 64 64" width="100%"> | ||
| 61 | <Defs> | ||
| 62 | <RadialGradient | ||
| 63 | cx={position(spec.accent.center[0])} | ||
| 64 | cy={position(spec.accent.center[1])} | ||
| 65 | fx={position(spec.accent.center[0])} | ||
| 66 | fy={position(spec.accent.center[1])} | ||
| 67 | id={accentGradientId} | ||
| 68 | r={radius(accentRadius)} | ||
| 69 | > | ||
| 70 | <Stop offset="0%" stopColor={colors.accent} stopOpacity={1} /> | ||
| 71 | <Stop | ||
| 72 | offset={position(spec.accent.middleOffset)} | ||
| 73 | stopColor={colors.accent} | ||
| 74 | stopOpacity={spec.accent.middleOpacity} | ||
| 75 | /> | ||
| 76 | <Stop | ||
| 77 | offset={position(spec.accent.endOffset)} | ||
| 78 | stopColor={colors.accent} | ||
| 79 | stopOpacity={0} | ||
| 80 | /> | ||
| 81 | <Stop offset="100%" stopColor={colors.accent} stopOpacity={0} /> | ||
| 82 | </RadialGradient> | ||
| 83 | <RadialGradient | ||
| 84 | cx={position(spec.action.center[0])} | ||
| 85 | cy={position(spec.action.center[1])} | ||
| 86 | fx={position(spec.action.center[0])} | ||
| 87 | fy={position(spec.action.center[1])} | ||
| 88 | id={actionGradientId} | ||
| 89 | r={radius(actionRadius)} | ||
| 90 | > | ||
| 91 | <Stop | ||
| 92 | offset="0%" | ||
| 93 | stopColor={colors.messageAction} | ||
| 94 | stopOpacity={spec.action.startOpacity} | ||
| 95 | /> | ||
| 96 | <Stop | ||
| 97 | offset={position(spec.action.endOffset)} | ||
| 98 | stopColor={colors.messageAction} | ||
| 99 | stopOpacity={0} | ||
| 100 | /> | ||
| 101 | <Stop offset="100%" stopColor={colors.messageAction} stopOpacity={0} /> | ||
| 102 | </RadialGradient> | ||
| 103 | </Defs> | ||
| 104 | <Circle cx="32" cy="32" fill={mixThemePreviewBase(colors, props.appearance)} r="32" /> | ||
| 105 | <Circle cx="32" cy="32" fill={`url(#${actionGradientId})`} r="32" /> | ||
| 106 | <Circle cx="32" cy="32" fill={`url(#${accentGradientId})`} r="32" /> | ||
| 107 | </Svg> | ||
| 108 | </View> | ||
| 109 | ); | ||
| 110 | }); | ||
| 111 | |||
| 112 | function ThemeCard(props: { | ||
| 113 | readonly disabled: boolean; | ||
| 114 | readonly darkSelected: boolean; | ||
| 115 | readonly label: string; | ||
| 116 | readonly lightSelected: boolean; | ||
| 117 | readonly onSelectBoth: () => void; | ||
| 118 | readonly onSelect: (appearance: MobileThemeAppearance) => void; | ||
| 119 | readonly themeId: MobileThemeId; | ||
| 120 | }) { | ||
| 121 | const badgeBackground = useThemeColor("--color-card"); | ||
| 122 | const badgeIcon = useThemeColor("--color-icon"); | ||
| 123 | |||
| 124 | const choice = (appearance: MobileThemeAppearance, selected: boolean) => ( | ||
| 125 | <Pressable | ||
| 126 | accessibilityLabel={`${props.label} ${appearance} theme`} | ||
| 127 | accessibilityRole="radio" | ||
| 128 | accessibilityState={{ checked: selected, disabled: props.disabled }} | ||
| 129 | className={ | ||
| 130 | selected | ||
| 131 | ? "size-[66px] items-center justify-center rounded-full border-[3px] border-primary" | ||
| 132 | : "size-[66px] items-center justify-center rounded-full border-[3px] border-transparent" | ||
| 133 | } | ||
| 134 | disabled={props.disabled} | ||
| 135 | onPress={() => props.onSelect(appearance)} | ||
| 136 | > | ||
| 137 | <PreviewOrb appearance={appearance} compact themeId={props.themeId} /> | ||
| 138 | {selected ? ( | ||
| 139 | <View | ||
| 140 | className="absolute -bottom-0.5 -right-0.5 size-5 items-center justify-center rounded-full border border-border" | ||
| 141 | style={{ backgroundColor: badgeBackground }} | ||
| 142 | > | ||
| 143 | <SymbolView | ||
| 144 | name={appearance === "light" ? "sun.max" : "moon"} | ||
| 145 | size={12} | ||
| 146 | tintColor={badgeIcon} | ||
| 147 | type="monochrome" | ||
| 148 | weight="medium" | ||
| 149 | /> | ||
| 150 | </View> | ||
| 151 | ) : null} | ||
| 152 | </Pressable> | ||
| 153 | ); | ||
| 154 | |||
| 155 | return ( | ||
| 156 | <View className="min-w-36 flex-1 basis-[47%] gap-3 rounded-[24px] border border-border bg-card px-2 py-4"> | ||
| 157 | <Pressable | ||
| 158 | accessibilityHint="Sets both light and dark appearances" | ||
| 159 | accessibilityLabel={`${props.label} theme`} | ||
| 160 | accessibilityRole="button" | ||
| 161 | accessibilityState={{ disabled: props.disabled }} | ||
| 162 | className="absolute inset-0 rounded-[24px] active:bg-subtle" | ||
| 163 | disabled={props.disabled} | ||
| 164 | onPress={props.onSelectBoth} | ||
| 165 | /> | ||
| 166 | <View className="flex-row items-center justify-center gap-2 py-1" pointerEvents="box-none"> | ||
| 167 | {choice("light", props.lightSelected)} | ||
| 168 | {choice("dark", props.darkSelected)} | ||
| 169 | </View> | ||
| 170 | <View className="min-h-8 flex-row items-center" pointerEvents="none"> | ||
| 171 | <Text className="min-w-0 flex-1 text-lg font-t3-medium" numberOfLines={1}> | ||
| 172 | {props.label} | ||
| 173 | </Text> | ||
| 174 | </View> | ||
| 175 | </View> | ||
| 176 | ); | ||
| 177 | } | ||
| 178 | |||
| 179 | function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly compact?: boolean }) { | ||
| 180 | return ( | ||
| 181 | <View | ||
| 182 | className="flex-1 overflow-hidden" | ||
| 183 | style={{ backgroundColor: props.colors["--color-screen"] }} | ||
| 184 | > | ||
| 185 | <View | ||
| 186 | className={props.compact ? "h-[18px] gap-0.5 px-1" : "h-[18px] gap-1 px-1.5"} | ||
| 187 | style={{ backgroundColor: props.colors["--color-card"] }} | ||
| 188 | > | ||
| 189 | <View className="mt-2 flex-row items-center gap-1"> | ||
| 190 | <View | ||
| 191 | className="size-1.5 rounded-full" | ||
| 192 | style={{ backgroundColor: props.colors["--color-primary"] }} | ||
| 193 | /> | ||
| 194 | <View | ||
| 195 | className="h-1 flex-1 rounded-full" | ||
| 196 | style={{ backgroundColor: props.colors["--color-foreground-muted"] }} | ||
| 197 | /> | ||
| 198 | </View> | ||
| 199 | </View> | ||
| 200 | <View | ||
| 201 | className={ | ||
| 202 | props.compact ? "flex-1 justify-between px-1 py-2" : "flex-1 justify-between px-1.5 py-2" | ||
| 203 | } | ||
| 204 | > | ||
| 205 | <View className="gap-1"> | ||
| 206 | <View | ||
| 207 | className="h-1.5 w-[72%] rounded-full" | ||
| 208 | style={{ backgroundColor: props.colors["--color-subtle-strong"] }} | ||
| 209 | /> | ||
| 210 | <View | ||
| 211 | className="h-1.5 w-[46%] rounded-full" | ||
| 212 | style={{ backgroundColor: props.colors["--color-subtle-strong"] }} | ||
| 213 | /> | ||
| 214 | </View> | ||
| 215 | <View className="items-end gap-1 pb-2"> | ||
| 216 | <View | ||
| 217 | className="h-3 w-[78%] rounded-full" | ||
| 218 | style={{ backgroundColor: props.colors["--color-user-bubble"] }} | ||
| 219 | /> | ||
| 220 | <View | ||
| 221 | className="h-1 w-[38%] rounded-full" | ||
| 222 | style={{ backgroundColor: props.colors["--color-foreground-muted"] }} | ||
| 223 | /> | ||
| 224 | </View> | ||
| 225 | </View> | ||
| 226 | </View> | ||
| 227 | ); | ||
| 228 | } | ||
| 229 | |||
| 230 | function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) { | ||
| 231 | const light = getMobileThemeVariables(props.themeIds.light, "light"); | ||
| 232 | const dark = getMobileThemeVariables(props.themeIds.dark, "dark"); | ||
| 233 | const currentBorder = useThemeColor("--color-border"); | ||
| 234 | const currentFrame = useThemeColor("--color-drawer"); | ||
| 235 | const currentIndicator = useThemeColor("--color-foreground-muted"); | ||
| 236 | const frameColor = | ||
| 237 | props.mode === "light" | ||
| 238 | ? light["--color-border"] | ||
| 239 | : props.mode === "dark" | ||
| 240 | ? dark["--color-border"] | ||
| 241 | : currentBorder; | ||
| 242 | const frameBackground = | ||
| 243 | props.mode === "light" | ||
| 244 | ? light["--color-drawer"] | ||
| 245 | : props.mode === "dark" | ||
| 246 | ? dark["--color-drawer"] | ||
| 247 | : currentFrame; | ||
| 248 | const indicatorColor = | ||
| 249 | props.mode === "light" | ||
| 250 | ? light["--color-foreground-muted"] | ||
| 251 | : props.mode === "dark" | ||
| 252 | ? dark["--color-foreground-muted"] | ||
| 253 | : currentIndicator; | ||
| 254 | |||
| 255 | return ( | ||
| 256 | <View | ||
| 257 | className="h-24 w-14 self-center rounded-[16px] p-[3px]" | ||
| 258 | style={{ backgroundColor: frameBackground, borderColor: frameColor, borderWidth: 1.5 }} | ||
| 259 | > | ||
| 260 | <View className="flex-1 flex-row overflow-hidden rounded-[11px]"> | ||
| 261 | {props.mode === "system" ? ( | ||
| 262 | <> | ||
| 263 | <PreviewPane colors={light} compact /> | ||
| 264 | <PreviewPane colors={dark} compact /> | ||
| 265 | </> | ||
| 266 | ) : ( | ||
| 267 | <PreviewPane colors={props.mode === "light" ? light : dark} /> | ||
| 268 | )} | ||
| 269 | </View> | ||
| 270 | <View | ||
| 271 | className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full" | ||
| 272 | style={{ backgroundColor: indicatorColor }} | ||
| 273 | /> | ||
| 274 | </View> | ||
| 275 | ); | ||
| 276 | } | ||
| 277 | |||
| 278 | function ModeCard(props: { | ||
| 279 | readonly disabled: boolean; | ||
| 280 | readonly label: string; | ||
| 281 | readonly mode: MobileThemeMode; | ||
| 282 | readonly onPress: () => void; | ||
| 283 | readonly selected: boolean; | ||
| 284 | readonly themeIds: MobileThemeIds; | ||
| 285 | }) { | ||
| 286 | return ( | ||
| 287 | <Pressable | ||
| 288 | accessibilityLabel={`${props.label} appearance`} | ||
| 289 | accessibilityRole="radio" | ||
| 290 | accessibilityState={{ checked: props.selected, disabled: props.disabled }} | ||
| 291 | className={ | ||
| 292 | props.selected | ||
| 293 | ? "min-w-0 flex-1 gap-2 rounded-[24px] border-2 border-primary bg-subtle p-2" | ||
| 294 | : "min-w-0 flex-1 gap-2 rounded-[24px] border border-border bg-card p-2" | ||
| 295 | } | ||
| 296 | disabled={props.disabled} | ||
| 297 | onPress={props.onPress} | ||
| 298 | > | ||
| 299 | <ModePreview mode={props.mode} themeIds={props.themeIds} /> | ||
| 300 | <Text | ||
| 301 | className={ | ||
| 302 | props.selected | ||
| 303 | ? "text-center text-base font-t3-bold text-foreground" | ||
| 304 | : "text-center text-base text-foreground-muted" | ||
| 305 | } | ||
| 306 | > | ||
| 307 | {props.label} | ||
| 308 | </Text> | ||
| 309 | </Pressable> | ||
| 310 | ); | ||
| 311 | } | ||
| 312 | |||
| 313 | function SectionLabel({ children }: { readonly children: string }) { | ||
| 314 | return <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{children}</Text>; | ||
| 315 | } | ||
| 316 | |||
| 317 | export function ThemeAppearanceSection() { | ||
| 318 | const { | ||
| 319 | isReady, | ||
| 320 | setThemeIdForAppearance, | ||
| 321 | setThemeIdForBothAppearances, | ||
| 322 | setThemeMode, | ||
| 323 | themeIds, | ||
| 324 | themeMode, | ||
| 325 | } = useAppearancePreferences(); | ||
| 326 | |||
| 327 | return ( | ||
| 328 | <View className="gap-6"> | ||
| 329 | <View className="gap-2"> | ||
| 330 | <SectionLabel>Color scheme</SectionLabel> | ||
| 331 | <View accessibilityRole="radiogroup" className="flex-row gap-2"> | ||
| 332 | {APPEARANCE_MODES.map((mode) => ( | ||
| 333 | <ModeCard | ||
| 334 | disabled={!isReady} | ||
| 335 | key={mode.id} | ||
| 336 | label={mode.label} | ||
| 337 | mode={mode.id} | ||
| 338 | onPress={() => setThemeMode(mode.id)} | ||
| 339 | selected={mode.id === themeMode} | ||
| 340 | themeIds={themeIds} | ||
| 341 | /> | ||
| 342 | ))} | ||
| 343 | </View> | ||
| 344 | </View> | ||
| 345 | |||
| 346 | <View className="gap-3"> | ||
| 347 | <SectionLabel>Themes</SectionLabel> | ||
| 348 | <View className="flex-row flex-wrap gap-3"> | ||
| 349 | {MOBILE_THEME_OPTIONS.map((theme) => ( | ||
| 350 | <ThemeCard | ||
| 351 | disabled={!isReady} | ||
| 352 | key={theme.id} | ||
| 353 | label={theme.label} | ||
| 354 | darkSelected={theme.id === themeIds.dark} | ||
| 355 | lightSelected={theme.id === themeIds.light} | ||
| 356 | onSelect={(appearance) => setThemeIdForAppearance(appearance, theme.id)} | ||
| 357 | onSelectBoth={() => setThemeIdForBothAppearances(theme.id)} | ||
| 358 | themeId={theme.id} | ||
| 359 | /> | ||
| 360 | ))} | ||
| 361 | </View> | ||
| 362 | </View> | ||
| 363 | </View> | ||
| 364 | ); | ||
| 365 | } | ||
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+3-6| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import type { ComponentProps } from "react"; | 1 | import type { ComponentProps } from "react"; |
| 2 | import { Switch, View } from "react-native"; | 2 | import { View } from "react-native"; |
| 3 | 3 | ||
| 4 | import { SymbolView } from "../../../components/AppSymbol"; | 4 | import { SymbolView } from "../../../components/AppSymbol"; |
| 5 | import { AppText as Text } from "../../../components/AppText"; | 5 | import { AppText as Text } from "../../../components/AppText"; |
| 6 | import { ThemedSwitch } from "../../../components/ThemedSwitch"; | ||
| 6 | import { useThemeColor } from "../../../lib/useThemeColor"; | 7 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| 7 | 8 | ||
| 8 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; | 9 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; |
| ... | @@ -16,8 +17,6 @@ export function SettingsSwitchRow(props: { | ... | @@ -16,8 +17,6 @@ export function SettingsSwitchRow(props: { |
| 16 | readonly onValueChange: (value: boolean) => void; | 17 | readonly onValueChange: (value: boolean) => void; |
| 17 | }) { | 18 | }) { |
| 18 | const icon = useThemeColor("--color-icon"); | 19 | const icon = useThemeColor("--color-icon"); |
| 19 | const activeTrack = String(useThemeColor("--color-switch-active")); | ||
| 20 | const track = String(useThemeColor("--color-secondary-border")); | ||
| 21 | 20 | ||
| 22 | return ( | 21 | return ( |
| 23 | <View | 22 | <View |
| ... | @@ -34,11 +33,9 @@ export function SettingsSwitchRow(props: { | ... | @@ -34,11 +33,9 @@ export function SettingsSwitchRow(props: { |
| 34 | <Text className="text-sm text-foreground-muted">{props.subtitle}</Text> | 33 | <Text className="text-sm text-foreground-muted">{props.subtitle}</Text> |
| 35 | ) : null} | 34 | ) : null} |
| 36 | </View> | 35 | </View> |
| 37 | <Switch | 36 | <ThemedSwitch |
| 38 | disabled={props.disabled} | 37 | disabled={props.disabled} |
| 39 | ios_backgroundColor={track} | ||
| 40 | onValueChange={props.onValueChange} | 38 | onValueChange={props.onValueChange} |
| 41 | trackColor={{ false: track, true: activeTrack }} | ||
| 42 | value={props.value} | 39 | value={props.value} |
| 43 | /> | 40 | /> |
| 44 | </View> | 41 | </View> |
apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx+65-14| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { useEffect, useRef, useState } from "react"; | 1 | import { useEffect, useRef, useState, useSyncExternalStore } from "react"; |
| 2 | import { Keyboard, View } from "react-native"; | 2 | import { Keyboard, View } from "react-native"; |
| 3 | import { | 3 | import { |
| 4 | CommonActions, | 4 | CommonActions, |
| ... | @@ -10,6 +10,8 @@ import { | ... | @@ -10,6 +10,8 @@ import { |
| 10 | import { AsyncResult } from "effect/unstable/reactivity"; | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 11 | 11 | ||
| 12 | import { useConnectionController } from "../connection/useConnectionController"; | 12 | import { useConnectionController } from "../connection/useConnectionController"; |
| 13 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 14 | import type { MobileThemeId } from "../../lib/mobileTheme"; | ||
| 13 | import { useProjects, useThreadShells } from "../../state/entities"; | 15 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 14 | import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; | 16 | import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; |
| 15 | import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; | 17 | import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; |
| ... | @@ -19,6 +21,7 @@ import { | ... | @@ -19,6 +21,7 @@ import { |
| 19 | getNativeShowcaseOrientation, | 21 | getNativeShowcaseOrientation, |
| 20 | getNativeShowcasePairingUrls, | 22 | getNativeShowcasePairingUrls, |
| 21 | getNativeShowcaseScene, | 23 | getNativeShowcaseScene, |
| 24 | getNativeShowcaseTheme, | ||
| 22 | markNativeShowcaseReady, | 25 | markNativeShowcaseReady, |
| 23 | type ShowcaseScene, | 26 | type ShowcaseScene, |
| 24 | } from "./nativeShowcaseScene"; | 27 | } from "./nativeShowcaseScene"; |
| ... | @@ -27,6 +30,12 @@ import { | ... | @@ -27,6 +30,12 @@ import { |
| 27 | SHOWCASE_PENDING_TASK_DEFINITIONS, | 30 | SHOWCASE_PENDING_TASK_DEFINITIONS, |
| 28 | } from "./showcasePendingTasks"; | 31 | } from "./showcasePendingTasks"; |
| 29 | import { retryShowcaseOperation } from "./showcaseRetry"; | 32 | import { retryShowcaseOperation } from "./showcaseRetry"; |
| 33 | import { | ||
| 34 | clearShowcaseRenderSignal, | ||
| 35 | getShowcaseRenderSignal, | ||
| 36 | isShowcaseNativeContentReady, | ||
| 37 | subscribeToShowcaseRenderSignal, | ||
| 38 | } from "./showcaseRenderSignal"; | ||
| 30 | 39 | ||
| 31 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; | 40 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| 32 | const SHOWCASE_THREAD_ID = "remote-command-center"; | 41 | const SHOWCASE_THREAD_ID = "remote-command-center"; |
| ... | @@ -48,6 +57,12 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null { | ... | @@ -48,6 +57,12 @@ function sceneFromPathname(pathname: string): ShowcaseScene | null { |
| 48 | export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { | 57 | export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { |
| 49 | const navigation = useNavigation(); | 58 | const navigation = useNavigation(); |
| 50 | const { connectPairingUrl } = useConnectionController(); | 59 | const { connectPairingUrl } = useConnectionController(); |
| 60 | const { | ||
| 61 | isReady: appearancePreferencesReady, | ||
| 62 | themeId, | ||
| 63 | themeIds, | ||
| 64 | setThemeIdForBothAppearances, | ||
| 65 | } = useAppearancePreferences(); | ||
| 51 | const workspace = useWorkspaceState(); | 66 | const workspace = useWorkspaceState(); |
| 52 | const projects = useProjects(); | 67 | const projects = useProjects(); |
| 53 | const threads = useThreadShells(); | 68 | const threads = useThreadShells(); |
| ... | @@ -56,18 +71,32 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) | ... | @@ -56,18 +71,32 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) |
| 56 | const [pairingUrls, setPairingUrls] = useState<ReadonlyArray<string>>([]); | 71 | const [pairingUrls, setPairingUrls] = useState<ReadonlyArray<string>>([]); |
| 57 | const [pendingTasksReady, setPendingTasksReady] = useState(false); | 72 | const [pendingTasksReady, setPendingTasksReady] = useState(false); |
| 58 | const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null); | 73 | const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null); |
| 74 | const [requestedTheme, setRequestedTheme] = useState<MobileThemeId | null>(null); | ||
| 75 | const [themeRequestSettled, setThemeRequestSettled] = useState(false); | ||
| 59 | const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null); | 76 | const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null); |
| 60 | const [orientationSettled, setOrientationSettled] = useState(false); | 77 | const [orientationSettled, setOrientationSettled] = useState(false); |
| 78 | const requestedSceneRef = useRef<ShowcaseScene | null>(null); | ||
| 79 | const renderSignal = useSyncExternalStore( | ||
| 80 | subscribeToShowcaseRenderSignal, | ||
| 81 | getShowcaseRenderSignal, | ||
| 82 | getShowcaseRenderSignal, | ||
| 83 | ); | ||
| 61 | 84 | ||
| 62 | useEffect(() => { | 85 | useEffect(() => { |
| 63 | if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; | 86 | if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; |
| 64 | 87 | ||
| 65 | const readPairingUrls = () => { | 88 | const readLaunchRequest = () => { |
| 66 | const values = getNativeShowcasePairingUrls(); | 89 | const values = getNativeShowcasePairingUrls(); |
| 67 | if (values.length > 0) setPairingUrls(values); | 90 | if (values.length === 0) return; |
| 91 | // The palette rides the same launch request as the pairing URLs, so | ||
| 92 | // reading it here settles it without a timeout that could expire while | ||
| 93 | // the request is still on its way. | ||
| 94 | setRequestedTheme(getNativeShowcaseTheme()); | ||
| 95 | setThemeRequestSettled(true); | ||
| 96 | setPairingUrls(values); | ||
| 68 | }; | 97 | }; |
| 69 | readPairingUrls(); | 98 | readLaunchRequest(); |
| 70 | const interval = setInterval(readPairingUrls, 250); | 99 | const interval = setInterval(readLaunchRequest, 250); |
| 71 | return () => clearInterval(interval); | 100 | return () => clearInterval(interval); |
| 72 | }, [pairingUrls.length]); | 101 | }, [pairingUrls.length]); |
| 73 | 102 | ||
| ... | @@ -95,13 +124,38 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) | ... | @@ -95,13 +124,38 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) |
| 95 | 124 | ||
| 96 | const readRequestedScene = () => { | 125 | const readRequestedScene = () => { |
| 97 | const value = getNativeShowcaseScene(); | 126 | const value = getNativeShowcaseScene(); |
| 98 | if (value) setRequestedScene(value); | 127 | if (!value || requestedSceneRef.current === value) return; |
| 128 | requestedSceneRef.current = value; | ||
| 129 | // A native draw belongs only to the scene request that produced it. In | ||
| 130 | // particular, revisiting review must wait for its newly mounted surface. | ||
| 131 | clearShowcaseRenderSignal(); | ||
| 132 | setRequestedScene(value); | ||
| 99 | }; | 133 | }; |
| 100 | readRequestedScene(); | 134 | readRequestedScene(); |
| 101 | const interval = setInterval(readRequestedScene, 250); | 135 | const interval = setInterval(readRequestedScene, 250); |
| 102 | return () => clearInterval(interval); | 136 | return () => clearInterval(interval); |
| 103 | }, []); | 137 | }, []); |
| 104 | 138 | ||
| 139 | // Captures pick a palette for both color schemes so the requested theme is | ||
| 140 | // used whichever system appearance the runner set on the device. | ||
| 141 | const themeApplied = | ||
| 142 | requestedTheme === null | ||
| 143 | ? themeRequestSettled | ||
| 144 | : themeIds.light === requestedTheme && themeIds.dark === requestedTheme; | ||
| 145 | |||
| 146 | useEffect(() => { | ||
| 147 | if ( | ||
| 148 | !SHOWCASE_ENABLED || | ||
| 149 | requestedTheme === null || | ||
| 150 | themeApplied || | ||
| 151 | // Writing before stored preferences load would be overwritten by them. | ||
| 152 | !appearancePreferencesReady | ||
| 153 | ) { | ||
| 154 | return; | ||
| 155 | } | ||
| 156 | setThemeIdForBothAppearances(requestedTheme); | ||
| 157 | }, [appearancePreferencesReady, requestedTheme, setThemeIdForBothAppearances, themeApplied]); | ||
| 158 | |||
| 105 | useEffect(() => { | 159 | useEffect(() => { |
| 106 | if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return; | 160 | if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return; |
| 107 | let cancelled = false; | 161 | let cancelled = false; |
| ... | @@ -219,17 +273,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) | ... | @@ -219,17 +273,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) |
| 219 | !hasFixture || | 273 | !hasFixture || |
| 220 | // Never report a scene ready while the capture orientation is still | 274 | // Never report a scene ready while the capture orientation is still |
| 221 | // being applied — a screenshot taken early has the wrong dimensions. | 275 | // being applied — a screenshot taken early has the wrong dimensions. |
| 222 | !orientationSettled | 276 | !orientationSettled || |
| 277 | // Likewise for the palette: an early screenshot shows the default theme. | ||
| 278 | !themeApplied || | ||
| 279 | !isShowcaseNativeContentReady({ scene, themeId, renderSignal }) | ||
| 223 | ) { | 280 | ) { |
| 224 | setReadyScene(null); | 281 | setReadyScene(null); |
| 225 | return; | 282 | return; |
| 226 | } | 283 | } |
| 227 | // Review owns its readiness marker because route activation happens before | ||
| 228 | // the VCS request is parsed and the native diff surface is mounted. | ||
| 229 | if (scene === "review") { | ||
| 230 | setReadyScene(null); | ||
| 231 | return; | ||
| 232 | } | ||
| 233 | if (scene === "terminal") Keyboard.dismiss(); | 284 | if (scene === "terminal") Keyboard.dismiss(); |
| 234 | 285 | ||
| 235 | let renderFrame: number | null = null; | 286 | let renderFrame: number | null = null; |
| ... | @@ -247,7 +298,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) | ... | @@ -247,7 +298,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) |
| 247 | if (renderFrame !== null) cancelAnimationFrame(renderFrame); | 298 | if (renderFrame !== null) cancelAnimationFrame(renderFrame); |
| 248 | if (readyFrame !== null) cancelAnimationFrame(readyFrame); | 299 | if (readyFrame !== null) cancelAnimationFrame(readyFrame); |
| 249 | }; | 300 | }; |
| 250 | }, [hasFixture, orientationSettled, requestedScene, scene]); | 301 | }, [hasFixture, orientationSettled, renderSignal, requestedScene, scene, themeApplied, themeId]); |
| 251 | 302 | ||
| 252 | if (!SHOWCASE_ENABLED || readyScene === null) return null; | 303 | if (!SHOWCASE_ENABLED || readyScene === null) return null; |
| 253 | 304 |
apps/mobile/src/features/showcase/nativeShowcaseScene.ts+17| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import { requireOptionalNativeModule } from "expo"; | 1 | import { requireOptionalNativeModule } from "expo"; |
| 2 | 2 | ||
| 3 | import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme"; | ||
| 4 | |||
| 3 | export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; | 5 | export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; |
| 4 | export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; | 6 | export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; |
| 5 | 7 | ||
| ... | @@ -8,6 +10,7 @@ export type ShowcaseOrientation = "portrait" | "landscape"; | ... | @@ -8,6 +10,7 @@ export type ShowcaseOrientation = "portrait" | "landscape"; |
| 8 | interface NativeShowcaseControls { | 10 | interface NativeShowcaseControls { |
| 9 | readonly getShowcasePairingUrl?: () => string | null; | 11 | readonly getShowcasePairingUrl?: () => string | null; |
| 10 | readonly getShowcaseScene?: () => string | null; | 12 | readonly getShowcaseScene?: () => string | null; |
| 13 | readonly getShowcaseTheme?: () => string | null; | ||
| 11 | readonly getShowcaseOrientation?: () => string | null; | 14 | readonly getShowcaseOrientation?: () => string | null; |
| 12 | readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise<void>; | 15 | readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise<void>; |
| 13 | readonly getInterfaceOrientation?: () => Promise<string>; | 16 | readonly getInterfaceOrientation?: () => Promise<string>; |
| ... | @@ -56,6 +59,20 @@ export function getNativeShowcaseScene(): ShowcaseScene | null { | ... | @@ -56,6 +59,20 @@ export function getNativeShowcaseScene(): ShowcaseScene | null { |
| 56 | } | 59 | } |
| 57 | } | 60 | } |
| 58 | 61 | ||
| 62 | /** | ||
| 63 | * Returns null when the runner requested no palette, which leaves the stored | ||
| 64 | * theme preference untouched. An unknown id also reads as null rather than | ||
| 65 | * silently falling back, so a capture never claims to show a theme it does not. | ||
| 66 | */ | ||
| 67 | export function getNativeShowcaseTheme(): MobileThemeId | null { | ||
| 68 | try { | ||
| 69 | const theme = nativeShowcaseControls()?.getShowcaseTheme?.()?.trim(); | ||
| 70 | return MOBILE_THEME_IDS.find((candidate) => candidate === theme) ?? null; | ||
| 71 | } catch { | ||
| 72 | return null; | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 59 | export function prepareNativeShowcaseCapture(): void { | 76 | export function prepareNativeShowcaseCapture(): void { |
| 60 | try { | 77 | try { |
| 61 | nativeShowcaseControls()?.prepareShowcaseCapture?.(); | 78 | nativeShowcaseControls()?.prepareShowcaseCapture?.(); |
apps/mobile/src/features/showcase/showcaseRenderSignal.test.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | clearShowcaseRenderSignal, | ||
| 5 | getShowcaseRenderSignal, | ||
| 6 | isShowcaseNativeContentReady, | ||
| 7 | reportShowcaseSceneRendered, | ||
| 8 | subscribeToShowcaseRenderSignal, | ||
| 9 | } from "./showcaseRenderSignal"; | ||
| 10 | |||
| 11 | afterEach(clearShowcaseRenderSignal); | ||
| 12 | |||
| 13 | describe("showcase native content readiness", () => { | ||
| 14 | it("does not gate scenes whose content is rendered by React Native", () => { | ||
| 15 | expect( | ||
| 16 | isShowcaseNativeContentReady({ scene: "environments", themeId: "grove", renderSignal: null }), | ||
| 17 | ).toBe(true); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("waits for the native review surface to draw the active theme", () => { | ||
| 21 | expect( | ||
| 22 | isShowcaseNativeContentReady({ scene: "review", themeId: "grove", renderSignal: null }), | ||
| 23 | ).toBe(false); | ||
| 24 | expect( | ||
| 25 | isShowcaseNativeContentReady({ | ||
| 26 | scene: "review", | ||
| 27 | themeId: "grove", | ||
| 28 | renderSignal: { scene: "review", themeId: "ocean" }, | ||
| 29 | }), | ||
| 30 | ).toBe(false); | ||
| 31 | expect( | ||
| 32 | isShowcaseNativeContentReady({ | ||
| 33 | scene: "review", | ||
| 34 | themeId: "grove", | ||
| 35 | renderSignal: { scene: "review", themeId: "grove" }, | ||
| 36 | }), | ||
| 37 | ).toBe(true); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("clears a draw when the runner requests another scene", () => { | ||
| 41 | const listener = vi.fn(); | ||
| 42 | const unsubscribe = subscribeToShowcaseRenderSignal(listener); | ||
| 43 | |||
| 44 | reportShowcaseSceneRendered({ scene: "review", themeId: "iris" }); | ||
| 45 | expect(getShowcaseRenderSignal()).toEqual({ scene: "review", themeId: "iris" }); | ||
| 46 | clearShowcaseRenderSignal(); | ||
| 47 | expect(getShowcaseRenderSignal()).toBeNull(); | ||
| 48 | expect(listener).toHaveBeenCalledTimes(2); | ||
| 49 | |||
| 50 | unsubscribe(); | ||
| 51 | }); | ||
| 52 | }); | ||
apps/mobile/src/features/showcase/showcaseRenderSignal.ts created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | import type { MobileThemeId } from "../../lib/mobileTheme"; | ||
| 2 | import type { ShowcaseScene } from "./nativeShowcaseScene"; | ||
| 3 | |||
| 4 | export type ShowcaseRenderSignal = Readonly<{ | ||
| 5 | scene: ShowcaseScene; | ||
| 6 | themeId: MobileThemeId; | ||
| 7 | }>; | ||
| 8 | |||
| 9 | const listeners = new Set<() => void>(); | ||
| 10 | let renderSignal: ShowcaseRenderSignal | null = null; | ||
| 11 | |||
| 12 | export function getShowcaseRenderSignal(): ShowcaseRenderSignal | null { | ||
| 13 | return renderSignal; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function subscribeToShowcaseRenderSignal(listener: () => void): () => void { | ||
| 17 | listeners.add(listener); | ||
| 18 | return () => listeners.delete(listener); | ||
| 19 | } | ||
| 20 | |||
| 21 | export function reportShowcaseSceneRendered(signal: ShowcaseRenderSignal): void { | ||
| 22 | renderSignal = signal; | ||
| 23 | for (const listener of listeners) listener(); | ||
| 24 | } | ||
| 25 | |||
| 26 | export function clearShowcaseRenderSignal(): void { | ||
| 27 | if (renderSignal === null) return; | ||
| 28 | renderSignal = null; | ||
| 29 | for (const listener of listeners) listener(); | ||
| 30 | } | ||
| 31 | |||
| 32 | export function isShowcaseNativeContentReady(input: { | ||
| 33 | readonly scene: ShowcaseScene; | ||
| 34 | readonly themeId: MobileThemeId; | ||
| 35 | readonly renderSignal: ShowcaseRenderSignal | null; | ||
| 36 | }): boolean { | ||
| 37 | if (input.scene !== "review") return true; | ||
| 38 | return input.renderSignal?.scene === "review" && input.renderSignal.themeId === input.themeId; | ||
| 39 | } | ||
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+7-7| ... | @@ -7,18 +7,18 @@ import { | ... | @@ -7,18 +7,18 @@ import { |
| 7 | type LayoutChangeEvent, | 7 | type LayoutChangeEvent, |
| 8 | type NativeSyntheticEvent, | 8 | type NativeSyntheticEvent, |
| 9 | type ViewProps, | 9 | type ViewProps, |
| 10 | useColorScheme, | ||
| 11 | } from "react-native"; | 10 | } from "react-native"; |
| 12 | 11 | ||
| 13 | import { AppText as Text } from "../../components/AppText"; | 12 | import { AppText as Text } from "../../components/AppText"; |
| 14 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 13 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 14 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 15 | import { | 15 | import { |
| 16 | getNativeTerminalHardwareKeyRevision, | 16 | getNativeTerminalHardwareKeyRevision, |
| 17 | resolveNativeTerminalSurfaceView, | 17 | resolveNativeTerminalSurfaceView, |
| 18 | } from "./nativeTerminalModule"; | 18 | } from "./nativeTerminalModule"; |
| 19 | import { | 19 | import { |
| 20 | buildGhosttyThemeConfig, | 20 | buildGhosttyThemeConfig, |
| 21 | getPierreTerminalTheme, | 21 | getMobileTerminalTheme, |
| 22 | type TerminalTheme, | 22 | type TerminalTheme, |
| 23 | } from "./terminalTheme"; | 23 | } from "./terminalTheme"; |
| 24 | import { terminalDebugLog } from "./terminalDebugLog"; | 24 | import { terminalDebugLog } from "./terminalDebugLog"; |
| ... | @@ -60,8 +60,8 @@ function estimateGridSize(input: { | ... | @@ -60,8 +60,8 @@ function estimateGridSize(input: { |
| 60 | const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { | 60 | const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { |
| 61 | const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; | 61 | const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; |
| 62 | const inputRef = useRef<TextInput>(null); | 62 | const inputRef = useRef<TextInput>(null); |
| 63 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; | 63 | const { themeAppearance, themeId } = useAppearancePreferences(); |
| 64 | const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); | 64 | const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance); |
| 65 | const statusLabel = props.isRunning | 65 | const statusLabel = props.isRunning |
| 66 | ? "Native terminal unavailable. Using text fallback." | 66 | ? "Native terminal unavailable. Using text fallback." |
| 67 | : "Open terminal to start a shell."; | 67 | : "Open terminal to start a shell."; |
| ... | @@ -173,8 +173,8 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter | ... | @@ -173,8 +173,8 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter |
| 173 | 173 | ||
| 174 | export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { | 174 | export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { |
| 175 | const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; | 175 | const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; |
| 176 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; | 176 | const { themeAppearance, themeId } = useAppearancePreferences(); |
| 177 | const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); | 177 | const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance); |
| 178 | const { onInput, onResize } = props; | 178 | const { onInput, onResize } = props; |
| 179 | const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); | 179 | const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); |
| 180 | const hasNativeSurface = Boolean(NativeTerminalSurfaceView); | 180 | const hasNativeSurface = Boolean(NativeTerminalSurfaceView); |
| ... | @@ -215,7 +215,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf | ... | @@ -215,7 +215,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf |
| 215 | return ( | 215 | return ( |
| 216 | <View style={props.style}> | 216 | <View style={props.style}> |
| 217 | <NativeTerminalSurfaceView | 217 | <NativeTerminalSurfaceView |
| 218 | appearanceScheme={appearanceScheme} | 218 | appearanceScheme={themeAppearance} |
| 219 | autoFocus={props.autoFocus ?? true} | 219 | autoFocus={props.autoFocus ?? true} |
| 220 | backgroundColor={theme.background} | 220 | backgroundColor={theme.background} |
| 221 | focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0} | 221 | focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0} |
apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx+8-6| ... | @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react"; | ... | @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react"; |
| 4 | import { Pressable, View } from "react-native"; | 4 | import { Pressable, View } from "react-native"; |
| 5 | 5 | ||
| 6 | import { AppText as Text } from "../../components/AppText"; | 6 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 7 | import { terminalEnvironment } from "../../state/terminal"; | 8 | import { terminalEnvironment } from "../../state/terminal"; |
| 8 | import { useAtomCommand } from "../../state/use-atom-command"; | 9 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 9 | import { useAttachedTerminalSession } from "../../state/use-terminal-session"; | 10 | import { useAttachedTerminalSession } from "../../state/use-terminal-session"; |
| ... | @@ -35,6 +36,7 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( | ... | @@ -35,6 +36,7 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( |
| 35 | const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); | 36 | const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); |
| 36 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); | 37 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); |
| 37 | const nativeTerminalAvailable = hasNativeTerminalSurface(); | 38 | const nativeTerminalAvailable = hasNativeTerminalSurface(); |
| 39 | const iconColor = useThemeColor("--color-icon"); | ||
| 38 | const terminalId = DEFAULT_TERMINAL_ID; | 40 | const terminalId = DEFAULT_TERMINAL_ID; |
| 39 | const lastGridSizeRef = useRef<TerminalGridSize>({ | 41 | const lastGridSizeRef = useRef<TerminalGridSize>({ |
| 40 | cols: DEFAULT_TERMINAL_COLS, | 42 | cols: DEFAULT_TERMINAL_COLS, |
| ... | @@ -214,13 +216,13 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( | ... | @@ -214,13 +216,13 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( |
| 214 | } | 216 | } |
| 215 | 217 | ||
| 216 | return ( | 218 | return ( |
| 217 | <View className="absolute inset-x-3 bottom-28 top-28 overflow-hidden rounded-[8px] border border-white/10 bg-neutral-950 shadow-2xl"> | 219 | <View className="absolute inset-x-3 bottom-28 top-28 overflow-hidden rounded-[8px] border border-border bg-screen shadow-2xl"> |
| 218 | <View className="flex-row items-center justify-between border-b border-white/10 px-3 py-2"> | 220 | <View className="flex-row items-center justify-between border-b border-border px-3 py-2"> |
| 219 | <View className="min-w-0 flex-1"> | 221 | <View className="min-w-0 flex-1"> |
| 220 | <Text className="font-t3-bold text-sm text-neutral-100" numberOfLines={1}> | 222 | <Text className="font-t3-bold text-sm text-foreground" numberOfLines={1}> |
| 221 | Terminal | 223 | Terminal |
| 222 | </Text> | 224 | </Text> |
| 223 | <Text className="text-2xs text-neutral-500" numberOfLines={1}> | 225 | <Text className="text-2xs text-foreground-muted" numberOfLines={1}> |
| 224 | {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"} | 226 | {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"} |
| 225 | </Text> | 227 | </Text> |
| 226 | </View> | 228 | </View> |
| ... | @@ -231,10 +233,10 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( | ... | @@ -231,10 +233,10 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( |
| 231 | </Text> | 233 | </Text> |
| 232 | ) : null} | 234 | ) : null} |
| 233 | <Pressable | 235 | <Pressable |
| 234 | className="h-8 w-8 items-center justify-center rounded-[8px] bg-white/10" | 236 | className="h-8 w-8 items-center justify-center rounded-[8px] bg-subtle" |
| 235 | onPress={props.onClose} | 237 | onPress={props.onClose} |
| 236 | > | 238 | > |
| 237 | <SymbolView name="xmark" size={13} tintColor="#e5e5e5" type="monochrome" /> | 239 | <SymbolView name="xmark" size={13} tintColor={iconColor} type="monochrome" /> |
| 238 | </Pressable> | 240 | </Pressable> |
| 239 | </View> | 241 | </View> |
| 240 | </View> | 242 | </View> |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+6-4| ... | @@ -5,7 +5,7 @@ import { SymbolView } from "../../components/AppSymbol"; | ... | @@ -5,7 +5,7 @@ import { SymbolView } from "../../components/AppSymbol"; |
| 5 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 5 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 6 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 6 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 8 | import { Platform, Pressable, View, useColorScheme } from "react-native"; | 8 | import { Platform, Pressable, View } from "react-native"; |
| 9 | import { | 9 | import { |
| 10 | KeyboardController, | 10 | KeyboardController, |
| 11 | KeyboardEvents, | 11 | KeyboardEvents, |
| ... | @@ -44,7 +44,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | ... | @@ -44,7 +44,7 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 44 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; | 44 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; |
| 45 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 45 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 46 | import { TerminalSurface } from "./NativeTerminalSurface"; | 46 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 47 | import { getPierreTerminalTheme } from "./terminalTheme"; | 47 | import { getMobileTerminalTheme } from "./terminalTheme"; |
| 48 | import { terminalDebugLog } from "./terminalDebugLog"; | 48 | import { terminalDebugLog } from "./terminalDebugLog"; |
| 49 | import { | 49 | import { |
| 50 | getTerminalBufferReplayKey, | 50 | getTerminalBufferReplayKey, |
| ... | @@ -166,7 +166,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -166,7 +166,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 166 | const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); | 166 | const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); |
| 167 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); | 167 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); |
| 168 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); | 168 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); |
| 169 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; | ||
| 170 | const { state: workspaceState } = useWorkspaceState(); | 169 | const { state: workspaceState } = useWorkspaceState(); |
| 171 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); | 170 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); |
| 172 | const params = props.route.params; | 171 | const params = props.route.params; |
| ... | @@ -186,6 +185,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -186,6 +185,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 186 | const { | 185 | const { |
| 187 | isReady: hasResolvedFontPreference, | 186 | isReady: hasResolvedFontPreference, |
| 188 | appearance, | 187 | appearance, |
| 188 | themeAppearance: appearanceScheme, | ||
| 189 | themeId, | ||
| 189 | setTerminalFontSize, | 190 | setTerminalFontSize, |
| 190 | } = useAppearancePreferences(); | 191 | } = useAppearancePreferences(); |
| 191 | const fontSize = appearance.terminalFontSize; | 192 | const fontSize = appearance.terminalFontSize; |
| ... | @@ -466,7 +467,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -466,7 +467,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 466 | [selectedEnvironmentConnection?.environmentLabel], | 467 | [selectedEnvironmentConnection?.environmentLabel], |
| 467 | ); | 468 | ); |
| 468 | 469 | ||
| 469 | const terminalTheme = getPierreTerminalTheme(appearanceScheme); | 470 | const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); |
| 470 | const usesNativeHeaderGlass = Platform.OS === "ios"; | 471 | const usesNativeHeaderGlass = Platform.OS === "ios"; |
| 471 | const pendingModifier = | 472 | const pendingModifier = |
| 472 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; | 473 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; |
| ... | @@ -1228,6 +1229,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -1228,6 +1229,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1228 | onResize={handleResize} | 1229 | onResize={handleResize} |
| 1229 | style={{ flex: 1 }} | 1230 | style={{ flex: 1 }} |
| 1230 | terminalKey={terminalKey} | 1231 | terminalKey={terminalKey} |
| 1232 | theme={terminalTheme} | ||
| 1231 | /> | 1233 | /> |
| 1232 | </View> | 1234 | </View> |
| 1233 | 1235 |
apps/mobile/src/features/terminal/terminalTheme.test.ts+35-1| ... | @@ -1,6 +1,13 @@ | ... | @@ -1,6 +1,13 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/themePalettes"; | ||
| 2 | 3 | ||
| 3 | import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme"; | 4 | import { themeColorToNativeColor } from "../../lib/mobileTheme"; |
| 5 | |||
| 6 | import { | ||
| 7 | buildGhosttyThemeConfig, | ||
| 8 | getMobileTerminalTheme, | ||
| 9 | getPierreTerminalTheme, | ||
| 10 | } from "./terminalTheme"; | ||
| 4 | 11 | ||
| 5 | describe("getPierreTerminalTheme", () => { | 12 | describe("getPierreTerminalTheme", () => { |
| 6 | it("returns the Pierre light terminal palette", () => { | 13 | it("returns the Pierre light terminal palette", () => { |
| ... | @@ -22,6 +29,33 @@ describe("getPierreTerminalTheme", () => { | ... | @@ -22,6 +29,33 @@ describe("getPierreTerminalTheme", () => { |
| 22 | }); | 29 | }); |
| 23 | }); | 30 | }); |
| 24 | 31 | ||
| 32 | describe("getMobileTerminalTheme", () => { | ||
| 33 | it("preserves the Pierre terminal for the default theme", () => { | ||
| 34 | for (const scheme of ["light", "dark"] as const) { | ||
| 35 | expect(getMobileTerminalTheme("t3-code", scheme)).toEqual(getPierreTerminalTheme(scheme)); | ||
| 36 | } | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("applies the selected palette without replacing ANSI status colors", () => { | ||
| 40 | const standard = getMobileTerminalTheme("t3-code", "dark"); | ||
| 41 | const ocean = getMobileTerminalTheme("ocean", "dark"); | ||
| 42 | |||
| 43 | expect(ocean.background).not.toBe(standard.background); | ||
| 44 | expect(ocean.cursorForeground).not.toBe(standard.cursorForeground); | ||
| 45 | expect(ocean.palette).toEqual(standard.palette); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("uses the canonical desktop terminal roles for built-in themes", () => { | ||
| 49 | const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === "ocean")!; | ||
| 50 | const colors = getThemeColorsForAppearance(theme, "dark")!; | ||
| 51 | const terminal = getMobileTerminalTheme("ocean", "dark"); | ||
| 52 | |||
| 53 | expect(terminal.background).toBe(themeColorToNativeColor(colors.terminalBackground)); | ||
| 54 | expect(terminal.foreground).toBe(themeColorToNativeColor(colors.terminalForeground)); | ||
| 55 | expect(terminal.cursorForeground).toBe(themeColorToNativeColor(colors.terminalCursor)); | ||
| 56 | }); | ||
| 57 | }); | ||
| 58 | |||
| 25 | describe("buildGhosttyThemeConfig", () => { | 59 | describe("buildGhosttyThemeConfig", () => { |
| 26 | it("serializes theme colors into a ghostty config file", () => { | 60 | it("serializes theme colors into a ghostty config file", () => { |
| 27 | const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark")); | 61 | const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark")); |
apps/mobile/src/features/terminal/terminalTheme.ts+30| ... | @@ -1,3 +1,11 @@ | ... | @@ -1,3 +1,11 @@ |
| 1 | import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/themePalettes"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | getMobileThemeVariables, | ||
| 5 | themeColorToNativeColor, | ||
| 6 | type MobileThemeId, | ||
| 7 | } from "../../lib/mobileTheme"; | ||
| 8 | |||
| 1 | export type TerminalAppearanceScheme = "light" | "dark"; | 9 | export type TerminalAppearanceScheme = "light" | "dark"; |
| 2 | 10 | ||
| 3 | export interface TerminalTheme { | 11 | export interface TerminalTheme { |
| ... | @@ -70,6 +78,28 @@ export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): Termin | ... | @@ -70,6 +78,28 @@ export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): Termin |
| 70 | return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; | 78 | return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; |
| 71 | } | 79 | } |
| 72 | 80 | ||
| 81 | export function getMobileTerminalTheme( | ||
| 82 | themeId: MobileThemeId, | ||
| 83 | scheme: TerminalAppearanceScheme, | ||
| 84 | ): TerminalTheme { | ||
| 85 | const base = getPierreTerminalTheme(scheme); | ||
| 86 | if (themeId === "t3-code") return base; | ||
| 87 | |||
| 88 | const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; | ||
| 89 | const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors; | ||
| 90 | const colors = getMobileThemeVariables(themeId, scheme); | ||
| 91 | const background = themeColorToNativeColor(palette.terminalBackground); | ||
| 92 | return { | ||
| 93 | ...base, | ||
| 94 | background, | ||
| 95 | foreground: themeColorToNativeColor(palette.terminalForeground), | ||
| 96 | mutedForeground: colors["--color-foreground-muted"], | ||
| 97 | border: colors["--color-border"], | ||
| 98 | cursorForeground: themeColorToNativeColor(palette.terminalCursor), | ||
| 99 | cursorBackground: background, | ||
| 100 | }; | ||
| 101 | } | ||
| 102 | |||
| 73 | export function buildGhosttyThemeConfig(theme: TerminalTheme): string { | 103 | export function buildGhosttyThemeConfig(theme: TerminalTheme): string { |
| 74 | const lines = [ | 104 | const lines = [ |
| 75 | `background = ${theme.background}`, | 105 | `background = ${theme.background}`, |
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+10-16| ... | @@ -2,11 +2,12 @@ import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; | ... | @@ -2,11 +2,12 @@ import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; |
| 2 | import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; | 2 | import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; |
| 3 | import { SymbolView } from "../../components/AppSymbol"; | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | import { memo } from "react"; | 4 | import { memo } from "react"; |
| 5 | import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; | 5 | import { Pressable, ScrollView, View, type ViewStyle } from "react-native"; |
| 6 | 6 | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | 7 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { GlassSurface } from "../../components/GlassSurface"; | 8 | import { GlassSurface } from "../../components/GlassSurface"; |
| 9 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; | 9 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| 10 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 10 | export type ComposerCommandItem = | 11 | export type ComposerCommandItem = |
| 11 | | { | 12 | | { |
| 12 | readonly id: string; | 13 | readonly id: string; |
| ... | @@ -45,11 +46,8 @@ interface ComposerCommandPopoverProps { | ... | @@ -45,11 +46,8 @@ interface ComposerCommandPopoverProps { |
| 45 | readonly onSelect: (item: ComposerCommandItem) => void; | 46 | readonly onSelect: (item: ComposerCommandItem) => void; |
| 46 | } | 47 | } |
| 47 | 48 | ||
| 48 | function PopoverSurface(props: { | 49 | function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) { |
| 49 | readonly children: React.ReactNode; | 50 | const tintColor = useThemeColor("--color-glass-surface"); |
| 50 | readonly isDarkMode: boolean; | ||
| 51 | readonly style?: ViewStyle; | ||
| 52 | }) { | ||
| 53 | const baseStyle: ViewStyle = { | 51 | const baseStyle: ViewStyle = { |
| 54 | borderRadius: 16, | 52 | borderRadius: 16, |
| 55 | overflow: "hidden", | 53 | overflow: "hidden", |
| ... | @@ -57,11 +55,7 @@ function PopoverSurface(props: { | ... | @@ -57,11 +55,7 @@ function PopoverSurface(props: { |
| 57 | }; | 55 | }; |
| 58 | 56 | ||
| 59 | return ( | 57 | return ( |
| 60 | <GlassSurface | 58 | <GlassSurface glassEffectStyle="clear" tintColor={tintColor} style={baseStyle}> |
| 61 | glassEffectStyle="clear" | ||
| 62 | tintColor={props.isDarkMode ? "rgba(30,30,32,0.95)" : "rgba(255,255,255,0.92)"} | ||
| 63 | style={baseStyle} | ||
| 64 | > | ||
| 65 | {props.children} | 59 | {props.children} |
| 66 | </GlassSurface> | 60 | </GlassSurface> |
| 67 | ); | 61 | ); |
| ... | @@ -114,7 +108,8 @@ const CommandRow = memo(function CommandRow(props: { | ... | @@ -114,7 +108,8 @@ const CommandRow = memo(function CommandRow(props: { |
| 114 | readonly isLast: boolean; | 108 | readonly isLast: boolean; |
| 115 | }) { | 109 | }) { |
| 116 | const iconName = itemIcon(props.item); | 110 | const iconName = itemIcon(props.item); |
| 117 | const iconColor = "#a1a1aa"; | 111 | const iconColor = useThemeColor("--color-icon-subtle"); |
| 112 | const borderColor = useThemeColor("--color-border"); | ||
| 118 | 113 | ||
| 119 | return ( | 114 | return ( |
| 120 | <Pressable | 115 | <Pressable |
| ... | @@ -127,7 +122,7 @@ const CommandRow = memo(function CommandRow(props: { | ... | @@ -127,7 +122,7 @@ const CommandRow = memo(function CommandRow(props: { |
| 127 | gap: 10, | 122 | gap: 10, |
| 128 | opacity: pressed ? 0.6 : 1, | 123 | opacity: pressed ? 0.6 : 1, |
| 129 | borderBottomWidth: props.isLast ? 0 : 0.5, | 124 | borderBottomWidth: props.isLast ? 0 : 0.5, |
| 130 | borderBottomColor: "rgba(255,255,255,0.1)", | 125 | borderBottomColor: borderColor, |
| 131 | })} | 126 | })} |
| 132 | > | 127 | > |
| 133 | {props.item.type === "path" ? ( | 128 | {props.item.type === "path" ? ( |
| ... | @@ -139,7 +134,7 @@ const CommandRow = memo(function CommandRow(props: { | ... | @@ -139,7 +134,7 @@ const CommandRow = memo(function CommandRow(props: { |
| 139 | {props.item.label} | 134 | {props.item.label} |
| 140 | </Text> | 135 | </Text> |
| 141 | {props.item.description ? ( | 136 | {props.item.description ? ( |
| 142 | <Text className="min-w-0 flex-1 text-xs text-zinc-400" numberOfLines={1}> | 137 | <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}> |
| 143 | {props.item.description} | 138 | {props.item.description} |
| 144 | </Text> | 139 | </Text> |
| 145 | ) : null} | 140 | ) : null} |
| ... | @@ -150,11 +145,10 @@ const CommandRow = memo(function CommandRow(props: { | ... | @@ -150,11 +145,10 @@ const CommandRow = memo(function CommandRow(props: { |
| 150 | export const ComposerCommandPopover = memo(function ComposerCommandPopover( | 145 | export const ComposerCommandPopover = memo(function ComposerCommandPopover( |
| 151 | props: ComposerCommandPopoverProps, | 146 | props: ComposerCommandPopoverProps, |
| 152 | ) { | 147 | ) { |
| 153 | const isDarkMode = useColorScheme() === "dark"; | ||
| 154 | const label = groupLabel(props.triggerKind); | 148 | const label = groupLabel(props.triggerKind); |
| 155 | 149 | ||
| 156 | return ( | 150 | return ( |
| 157 | <PopoverSurface isDarkMode={isDarkMode}> | 151 | <PopoverSurface> |
| 158 | {label ? ( | 152 | {label ? ( |
| 159 | <View className="px-3.5 pt-2.5 pb-1"> | 153 | <View className="px-3.5 pt-2.5 pb-1"> |
| 160 | <Text className="text-3xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted"> | 154 | <Text className="text-3xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted"> |
apps/mobile/src/features/threads/GitActionProgressOverlay.tsx+4-2| ... | @@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics"; | ... | @@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics"; |
| 2 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | 2 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; |
| 3 | import { SymbolView } from "../../components/AppSymbol"; | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | import { useCallback, useEffect, useRef } from "react"; | 4 | import { useCallback, useEffect, useRef } from "react"; |
| 5 | import { ActivityIndicator, Pressable, StyleSheet, useColorScheme, View } from "react-native"; | 5 | import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native"; |
| 6 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; | 6 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 8 | ||
| ... | @@ -11,6 +11,7 @@ import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics"; | ... | @@ -11,6 +11,7 @@ import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 11 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | 11 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 12 | import { useThemeColor } from "../../lib/useThemeColor"; | 12 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 13 | import type { GitActionProgress } from "../../state/use-vcs-action-state"; | 13 | import type { GitActionProgress } from "../../state/use-vcs-action-state"; |
| 14 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 14 | 15 | ||
| 15 | const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220); | 16 | const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220); |
| 16 | const OVERLAY_TOP_GAP = 8; | 17 | const OVERLAY_TOP_GAP = 8; |
| ... | @@ -69,7 +70,8 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { | ... | @@ -69,7 +70,8 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { |
| 69 | const iconColor = useThemeColor("--color-icon"); | 70 | const iconColor = useThemeColor("--color-icon"); |
| 70 | const glassBorder = useThemeColor("--color-header-border"); | 71 | const glassBorder = useThemeColor("--color-header-border"); |
| 71 | const glassTint = useThemeColor("--color-glass-tint"); | 72 | const glassTint = useThemeColor("--color-glass-tint"); |
| 72 | const isDarkMode = useColorScheme() === "dark"; | 73 | const { themeAppearance } = useAppearancePreferences(); |
| 74 | const isDarkMode = themeAppearance === "dark"; | ||
| 73 | const content = ( | 75 | const content = ( |
| 74 | <> | 76 | <> |
| 75 | <OverlayIcon phase={progress.phase} iconColor={iconColor} /> | 77 | <OverlayIcon phase={progress.phase} iconColor={iconColor} /> |
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+2-2| ... | @@ -13,7 +13,6 @@ import { | ... | @@ -13,7 +13,6 @@ import { |
| 13 | Platform, | 13 | Platform, |
| 14 | Pressable, | 14 | Pressable, |
| 15 | ScrollView, | 15 | ScrollView, |
| 16 | Switch, | ||
| 17 | TextInput, | 16 | TextInput, |
| 18 | View, | 17 | View, |
| 19 | } from "react-native"; | 18 | } from "react-native"; |
| ... | @@ -22,6 +21,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -22,6 +21,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 22 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 21 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 23 | import { SymbolView } from "../../components/AppSymbol"; | 22 | import { SymbolView } from "../../components/AppSymbol"; |
| 24 | import { AppText as Text } from "../../components/AppText"; | 23 | import { AppText as Text } from "../../components/AppText"; |
| 24 | import { ThemedSwitch } from "../../components/ThemedSwitch"; | ||
| 25 | import { cn } from "../../lib/cn"; | 25 | import { cn } from "../../lib/cn"; |
| 26 | import { useFontFamily } from "../../lib/useFontFamily"; | 26 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 27 | import { useThemeColor } from "../../lib/useThemeColor"; | 27 | import { useThemeColor } from "../../lib/useThemeColor"; |
| ... | @@ -97,7 +97,7 @@ function ToggleRow(props: { | ... | @@ -97,7 +97,7 @@ function ToggleRow(props: { |
| 97 | <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}> | 97 | <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground" numberOfLines={1}> |
| 98 | {props.title} | 98 | {props.title} |
| 99 | </Text> | 99 | </Text> |
| 100 | <Switch | 100 | <ThemedSwitch |
| 101 | accessibilityLabel={props.title} | 101 | accessibilityLabel={props.title} |
| 102 | onValueChange={props.onValueChange} | 102 | onValueChange={props.onValueChange} |
| 103 | value={props.value} | 103 | value={props.value} |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+7-4| ... | @@ -6,7 +6,7 @@ import { | ... | @@ -6,7 +6,7 @@ import { |
| 6 | usePreventRemove, | 6 | usePreventRemove, |
| 7 | } from "@react-navigation/native"; | 7 | } from "@react-navigation/native"; |
| 8 | import { useCallback, useEffect, useRef, useState } from "react"; | 8 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 9 | import { Alert, Platform, Pressable, ScrollView, View, useColorScheme } from "react-native"; | 9 | import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; |
| 10 | import { | 10 | import { |
| 11 | KeyboardController, | 11 | KeyboardController, |
| 12 | KeyboardStickyView, | 12 | KeyboardStickyView, |
| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | } from "react-native-keyboard-controller"; | 14 | } from "react-native-keyboard-controller"; |
| 15 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 15 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 16 | import { useThemeColor } from "../../lib/useThemeColor"; | 16 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 17 | import { themeColorWithAlpha } from "../../lib/mobileTheme"; | ||
| 17 | import { useFontFamily } from "../../lib/useFontFamily"; | 18 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 18 | 19 | ||
| 19 | import { | 20 | import { |
| ... | @@ -42,6 +43,7 @@ import { | ... | @@ -42,6 +43,7 @@ import { |
| 42 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | 43 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; |
| 43 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; | 44 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 44 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 45 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 46 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 45 | import { | 47 | import { |
| 46 | clearComposerDraftContent, | 48 | clearComposerDraftContent, |
| 47 | getComposerDraftSnapshot, | 49 | getComposerDraftSnapshot, |
| ... | @@ -107,7 +109,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -107,7 +109,7 @@ export function NewTaskDraftScreen(props: { |
| 107 | reserveShare, | 109 | reserveShare, |
| 108 | } = useIncomingShare(); | 110 | } = useIncomingShare(); |
| 109 | const insets = useSafeAreaInsets(); | 111 | const insets = useSafeAreaInsets(); |
| 110 | const colorScheme = useColorScheme(); | 112 | const { themeAppearance: colorScheme } = useAppearancePreferences(); |
| 111 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); | 113 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| 112 | const controlsBottomPadding = Math.max(insets.bottom, 10); | 114 | const controlsBottomPadding = Math.max(insets.bottom, 10); |
| 113 | const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); | 115 | const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); |
| ... | @@ -290,11 +292,12 @@ export function NewTaskDraftScreen(props: { | ... | @@ -290,11 +292,12 @@ export function NewTaskDraftScreen(props: { |
| 290 | }, [props.pendingTaskId, cancelEditingPendingTask]); | 292 | }, [props.pendingTaskId, cancelEditingPendingTask]); |
| 291 | 293 | ||
| 292 | const foregroundColor = useThemeColor("--color-foreground"); | 294 | const foregroundColor = useThemeColor("--color-foreground"); |
| 295 | const sheetColor = String(useThemeColor("--color-sheet")); | ||
| 293 | const projectUnderlineColor = useThemeColor("--color-foreground-muted"); | 296 | const projectUnderlineColor = useThemeColor("--color-foreground-muted"); |
| 294 | const regularFontFamily = useFontFamily("regular"); | 297 | const regularFontFamily = useFontFamily("regular"); |
| 295 | const bodyText = useScaledTextRole("body"); | 298 | const bodyText = useScaledTextRole("body"); |
| 296 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; | 299 | const sheetFadeOpaque = sheetColor; |
| 297 | const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; | 300 | const sheetFadeTransparent = themeColorWithAlpha(sheetColor, 0); |
| 298 | 301 | ||
| 299 | // A new navigation to this mounted screen delivers a fresh initialProjectRef | 302 | // A new navigation to this mounted screen delivers a fresh initialProjectRef |
| 300 | // reference — treat it as a new request and let it apply again. | 303 | // reference — treat it as a new request and let it apply again. |
apps/mobile/src/features/threads/ThreadComposer.tsx+19-12| ... | @@ -22,7 +22,6 @@ import { | ... | @@ -22,7 +22,6 @@ import { |
| 22 | Platform, | 22 | Platform, |
| 23 | Pressable, | 23 | Pressable, |
| 24 | StyleSheet, | 24 | StyleSheet, |
| 25 | useColorScheme, | ||
| 26 | View, | 25 | View, |
| 27 | type ViewStyle, | 26 | type ViewStyle, |
| 28 | } from "react-native"; | 27 | } from "react-native"; |
| ... | @@ -35,6 +34,7 @@ import Animated, { | ... | @@ -35,6 +34,7 @@ import Animated, { |
| 35 | LinearTransition, | 34 | LinearTransition, |
| 36 | } from "react-native-reanimated"; | 35 | } from "react-native-reanimated"; |
| 37 | import { useThemeColor } from "../../lib/useThemeColor"; | 36 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 37 | import { themeColorWithAlpha } from "../../lib/mobileTheme"; | ||
| 38 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | 38 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 39 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 39 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 40 | 40 | ||
| ... | @@ -57,6 +57,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; | ... | @@ -57,6 +57,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; |
| 57 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 57 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 58 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 58 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 59 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 59 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 60 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 60 | import type { RemoteClientConnectionState } from "../../lib/connection"; | 61 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 61 | import { | 62 | import { |
| 62 | insertRankedSearchResult, | 63 | insertRankedSearchResult, |
| ... | @@ -144,11 +145,14 @@ export function ComposerSurface(props: { | ... | @@ -144,11 +145,14 @@ export function ComposerSurface(props: { |
| 144 | /** Existing thread composers morph between pill and card layouts. */ | 145 | /** Existing thread composers morph between pill and card layouts. */ |
| 145 | readonly animateLayout?: boolean; | 146 | readonly animateLayout?: boolean; |
| 146 | }) { | 147 | }) { |
| 148 | const cardColor = useThemeColor("--color-card-translucent"); | ||
| 149 | const borderColor = useThemeColor("--color-border"); | ||
| 150 | const shadowColor = useThemeColor("--color-primary-shadow"); | ||
| 147 | // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself | 151 | // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself |
| 148 | // (needed to clip content to the pill shape) would clip the shadow on iOS. | 152 | // (needed to clip content to the pill shape) would clip the shadow on iOS. |
| 149 | const shadowStyle: ViewStyle = { | 153 | const shadowStyle: ViewStyle = { |
| 150 | borderRadius: props.style.borderRadius, | 154 | borderRadius: props.style.borderRadius, |
| 151 | shadowColor: "#000000", | 155 | shadowColor, |
| 152 | shadowOpacity: props.isDarkMode ? 0.35 : 0.12, | 156 | shadowOpacity: props.isDarkMode ? 0.35 : 0.12, |
| 153 | shadowRadius: 14, | 157 | shadowRadius: 14, |
| 154 | shadowOffset: { width: 0, height: 6 }, | 158 | shadowOffset: { width: 0, height: 6 }, |
| ... | @@ -163,9 +167,9 @@ export function ComposerSurface(props: { | ... | @@ -163,9 +167,9 @@ export function ComposerSurface(props: { |
| 163 | <GlassSurface | 167 | <GlassSurface |
| 164 | chrome="none" | 168 | chrome="none" |
| 165 | fallbackStyle={{ | 169 | fallbackStyle={{ |
| 166 | backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", | 170 | backgroundColor: cardColor, |
| 167 | borderWidth: 1, | 171 | borderWidth: 1, |
| 168 | borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", | 172 | borderColor, |
| 169 | }} | 173 | }} |
| 170 | glassEffectStyle="regular" | 174 | glassEffectStyle="regular" |
| 171 | // The composer is a passive material containing interactive controls. | 175 | // The composer is a passive material containing interactive controls. |
| ... | @@ -235,6 +239,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( | ... | @@ -235,6 +239,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 235 | readonly status: ComposerStatusPillState; | 239 | readonly status: ComposerStatusPillState; |
| 236 | }) { | 240 | }) { |
| 237 | const isReconnecting = props.status.kind !== "unavailable"; | 241 | const isReconnecting = props.status.kind !== "unavailable"; |
| 242 | const indicatorColor = useThemeColor("--color-icon-muted"); | ||
| 238 | 243 | ||
| 239 | return ( | 244 | return ( |
| 240 | <Animated.View | 245 | <Animated.View |
| ... | @@ -246,10 +251,10 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( | ... | @@ -246,10 +251,10 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 246 | <Pressable | 251 | <Pressable |
| 247 | accessibilityRole="button" | 252 | accessibilityRole="button" |
| 248 | onPress={props.onPress} | 253 | onPress={props.onPress} |
| 249 | className="max-w-full flex-row items-center gap-2 rounded-full bg-white/90 px-3 py-2 shadow-sm active:opacity-70 dark:bg-neutral-900/90" | 254 | className="max-w-full flex-row items-center gap-2 rounded-full bg-card px-3 py-2 shadow-sm active:opacity-70" |
| 250 | > | 255 | > |
| 251 | {isReconnecting ? ( | 256 | {isReconnecting ? ( |
| 252 | <ActivityIndicator size="small" color="#8e8e93" /> | 257 | <ActivityIndicator size="small" color={indicatorColor} /> |
| 253 | ) : ( | 258 | ) : ( |
| 254 | <View className="h-2 w-2 rounded-full bg-red-500" /> | 259 | <View className="h-2 w-2 rounded-full bg-red-500" /> |
| 255 | )} | 260 | )} |
| ... | @@ -266,7 +271,8 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( | ... | @@ -266,7 +271,8 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 266 | 271 | ||
| 267 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { | 272 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 268 | const navigation = useNavigation(); | 273 | const navigation = useNavigation(); |
| 269 | const isDarkMode = useColorScheme() === "dark"; | 274 | const { themeAppearance } = useAppearancePreferences(); |
| 275 | const isDarkMode = themeAppearance === "dark"; | ||
| 270 | const foregroundColor = useThemeColor("--color-foreground"); | 276 | const foregroundColor = useThemeColor("--color-foreground"); |
| 271 | const bodyText = useScaledTextRole("body"); | 277 | const bodyText = useScaledTextRole("body"); |
| 272 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); | 278 | const fallbackInputRef = useRef<ComposerEditorHandle>(null); |
| ... | @@ -335,8 +341,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -335,8 +341,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 335 | environmentLabel: props.environmentLabel, | 341 | environmentLabel: props.environmentLabel, |
| 336 | threadSyncPhase: props.threadSyncPhase, | 342 | threadSyncPhase: props.threadSyncPhase, |
| 337 | }); | 343 | }); |
| 338 | const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; | 344 | const toolbarSurface = String(useThemeColor("--color-card")); |
| 339 | const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; | 345 | const backdropSurface = String(useThemeColor("--color-screen")); |
| 346 | const toolbarFadeOpaque = themeColorWithAlpha(toolbarSurface, 0.95); | ||
| 347 | const toolbarFadeTransparent = themeColorWithAlpha(toolbarSurface, 0); | ||
| 348 | const backdropGradient = `linear-gradient(to bottom, ${themeColorWithAlpha(backdropSurface, 0)} 0%, ${themeColorWithAlpha(backdropSurface, 0.6)} 55%, ${themeColorWithAlpha(backdropSurface, 0.9)} 100%)`; | ||
| 340 | const selectedProviderStatus = useMemo(() => { | 349 | const selectedProviderStatus = useMemo(() => { |
| 341 | if (!props.serverConfig) return null; | 350 | if (!props.serverConfig) return null; |
| 342 | return ( | 351 | return ( |
| ... | @@ -704,9 +713,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -704,9 +713,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 704 | style={[ | 713 | style={[ |
| 705 | StyleSheet.absoluteFill, | 714 | StyleSheet.absoluteFill, |
| 706 | { | 715 | { |
| 707 | experimental_backgroundImage: isDarkMode | 716 | experimental_backgroundImage: backdropGradient, |
| 708 | ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 55%, rgba(0,0,0,0.9) 100%)" | ||
| 709 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)", | ||
| 710 | }, | 717 | }, |
| 711 | ]} | 718 | ]} |
| 712 | /> | 719 | /> |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+3-2| ... | @@ -32,7 +32,6 @@ import { | ... | @@ -32,7 +32,6 @@ import { |
| 32 | AppState, | 32 | AppState, |
| 33 | Keyboard, | 33 | Keyboard, |
| 34 | Platform, | 34 | Platform, |
| 35 | useColorScheme, | ||
| 36 | useWindowDimensions, | 35 | useWindowDimensions, |
| 37 | View, | 36 | View, |
| 38 | type GestureResponderEvent, | 37 | type GestureResponderEvent, |
| ... | @@ -53,6 +52,7 @@ import Animated, { | ... | @@ -53,6 +52,7 @@ import Animated, { |
| 53 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 52 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 54 | 53 | ||
| 55 | import { ControlPill } from "../../components/ControlPill"; | 54 | import { ControlPill } from "../../components/ControlPill"; |
| 55 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 56 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 56 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 57 | import type { StatusTone } from "../../components/StatusPill"; | 57 | import type { StatusTone } from "../../components/StatusPill"; |
| 58 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 58 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| ... | @@ -539,7 +539,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -539,7 +539,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 539 | }, [freeze, scrollMessageToEnd]); | 539 | }, [freeze, scrollMessageToEnd]); |
| 540 | 540 | ||
| 541 | const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; | 541 | const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; |
| 542 | const isDarkMode = useColorScheme() === "dark"; | 542 | const { themeAppearance } = useAppearancePreferences(); |
| 543 | const isDarkMode = themeAppearance === "dark"; | ||
| 543 | 544 | ||
| 544 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { | 545 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { |
| 545 | feedTouchStartRef.current = { | 546 | feedTouchStartRef.current = { |
apps/mobile/src/features/threads/ThreadFeed.tsx+45-73| ... | @@ -37,7 +37,6 @@ import { | ... | @@ -37,7 +37,6 @@ import { |
| 37 | StyleSheet, | 37 | StyleSheet, |
| 38 | Text as NativeText, | 38 | Text as NativeText, |
| 39 | type ColorValue, | 39 | type ColorValue, |
| 40 | useColorScheme, | ||
| 41 | useWindowDimensions, | 40 | useWindowDimensions, |
| 42 | View, | 41 | View, |
| 43 | } from "react-native"; | 42 | } from "react-native"; |
| ... | @@ -194,43 +193,6 @@ function MessageAttachmentImage(props: { | ... | @@ -194,43 +193,6 @@ function MessageAttachmentImage(props: { |
| 194 | ); | 193 | ); |
| 195 | } | 194 | } |
| 196 | 195 | ||
| 197 | const MARKDOWN_COLORS = { | ||
| 198 | light: { | ||
| 199 | body: "#111111", | ||
| 200 | strong: "#000000", | ||
| 201 | link: "#2563eb", | ||
| 202 | blockquoteBorder: "rgba(0, 0, 0, 0.08)", | ||
| 203 | blockquoteBackground: "rgba(0, 0, 0, 0.02)", | ||
| 204 | codeBackground: "rgba(0, 0, 0, 0.04)", | ||
| 205 | codeText: "#262626", | ||
| 206 | inlineCodeText: "#5f6368", | ||
| 207 | horizontalRule: "rgba(0, 0, 0, 0.08)", | ||
| 208 | userBody: "#ffffff", | ||
| 209 | userCodeBackground: "rgba(255, 255, 255, 0.22)", | ||
| 210 | userCodeText: "#ffffff", | ||
| 211 | userInlineCodeText: "rgba(255, 255, 255, 0.82)", | ||
| 212 | userFenceBackground: "rgba(0, 0, 0, 0.16)", | ||
| 213 | userFenceText: "#ffffff", | ||
| 214 | }, | ||
| 215 | dark: { | ||
| 216 | body: "#e5e5e5", | ||
| 217 | strong: "#f5f5f5", | ||
| 218 | link: "#60a5fa", | ||
| 219 | blockquoteBorder: "rgba(255, 255, 255, 0.1)", | ||
| 220 | blockquoteBackground: "rgba(255, 255, 255, 0.03)", | ||
| 221 | codeBackground: "rgba(255, 255, 255, 0.06)", | ||
| 222 | codeText: "#e5e5e5", | ||
| 223 | inlineCodeText: "#b8bcc2", | ||
| 224 | horizontalRule: "rgba(255, 255, 255, 0.08)", | ||
| 225 | userBody: "#ffffff", | ||
| 226 | userCodeBackground: "rgba(255, 255, 255, 0.18)", | ||
| 227 | userCodeText: "#ffffff", | ||
| 228 | userInlineCodeText: "rgba(255, 255, 255, 0.82)", | ||
| 229 | userFenceBackground: "rgba(0, 0, 0, 0.28)", | ||
| 230 | userFenceText: "#ffffff", | ||
| 231 | }, | ||
| 232 | } as const; | ||
| 233 | |||
| 234 | const MARKDOWN_MONO_FONT = Platform.select({ | 196 | const MARKDOWN_MONO_FONT = Platform.select({ |
| 235 | ios: "ui-monospace", | 197 | ios: "ui-monospace", |
| 236 | android: "monospace", | 198 | android: "monospace", |
| ... | @@ -426,14 +388,12 @@ function MarkdownCodeBlock(props: { | ... | @@ -426,14 +388,12 @@ function MarkdownCodeBlock(props: { |
| 426 | } | 388 | } |
| 427 | 389 | ||
| 428 | function useReviewCommentColors(): ReviewCommentColors { | 390 | function useReviewCommentColors(): ReviewCommentColors { |
| 429 | const colorScheme = useColorScheme(); | 391 | const background = useThemeColor("--color-card"); |
| 430 | const isDark = colorScheme === "dark"; | 392 | const border = useThemeColor("--color-border"); |
| 431 | const background = isDark ? "#151515" : "#ffffff"; | 393 | const mutedBackground = useThemeColor("--color-subtle"); |
| 432 | const border = isDark ? "#2a2a2a" : "#d7d7d7"; | 394 | const text = useThemeColor("--color-foreground"); |
| 433 | const mutedBackground = isDark ? "#242424" : "#f2f2f2"; | 395 | const mutedText = useThemeColor("--color-foreground-muted"); |
| 434 | const text = isDark ? "#f3f3f3" : "#111111"; | 396 | const codeBackground = useThemeColor("--color-md-code-bg"); |
| 435 | const mutedText = isDark ? "#8f8f8f" : "#666666"; | ||
| 436 | const codeBackground = isDark ? "#0f0f0f" : "#ffffff"; | ||
| 437 | 397 | ||
| 438 | return useMemo( | 398 | return useMemo( |
| 439 | () => ({ | 399 | () => ({ |
| ... | @@ -449,8 +409,7 @@ function useReviewCommentColors(): ReviewCommentColors { | ... | @@ -449,8 +409,7 @@ function useReviewCommentColors(): ReviewCommentColors { |
| 449 | } | 409 | } |
| 450 | 410 | ||
| 451 | function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { | 411 | function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { |
| 452 | const colorScheme = useColorScheme(); | 412 | const { appearance, themeAppearance } = useAppearancePreferences(); |
| 453 | const { appearance } = useAppearancePreferences(); | ||
| 454 | const markdownFontSizes = useMemo( | 413 | const markdownFontSizes = useMemo( |
| 455 | () => resolveMarkdownFontSizes(appearance.baseFontSize), | 414 | () => resolveMarkdownFontSizes(appearance.baseFontSize), |
| 456 | [appearance.baseFontSize], | 415 | [appearance.baseFontSize], |
| ... | @@ -459,31 +418,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -459,31 +418,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 459 | () => resolveNativeMarkdownTypography(appearance.baseFontSize), | 418 | () => resolveNativeMarkdownTypography(appearance.baseFontSize), |
| 460 | [appearance.baseFontSize], | 419 | [appearance.baseFontSize], |
| 461 | ); | 420 | ); |
| 462 | const themeMode = colorScheme === "dark" ? "dark" : "light"; | 421 | const themeMode = themeAppearance; |
| 463 | const colors = MARKDOWN_COLORS[themeMode]; | 422 | const markdownBodyColor = String(useThemeColor("--color-md-body")); |
| 423 | const markdownStrongColor = String(useThemeColor("--color-md-strong")); | ||
| 424 | const markdownLinkColor = String(useThemeColor("--color-md-link")); | ||
| 425 | const markdownBlockquoteBg = String(useThemeColor("--color-md-blockquote-bg")); | ||
| 426 | const markdownBlockquoteBorder = String(useThemeColor("--color-md-blockquote-border")); | ||
| 427 | const markdownCodeBg = String(useThemeColor("--color-md-code-bg")); | ||
| 428 | const markdownCodeText = String(useThemeColor("--color-md-code-text")); | ||
| 429 | const markdownInlineCodeText = String(useThemeColor("--color-foreground-secondary")); | ||
| 430 | const markdownHrColor = String(useThemeColor("--color-md-hr")); | ||
| 431 | const markdownUserBodyColor = String(useThemeColor("--color-user-bubble-foreground")); | ||
| 432 | const markdownUserCodeBg = String(useThemeColor("--color-md-user-code-bg")); | ||
| 433 | const markdownUserCodeText = String(useThemeColor("--color-md-user-code-text")); | ||
| 434 | const markdownUserInlineCodeText = String(useThemeColor("--color-user-bubble-foreground-muted")); | ||
| 435 | const markdownUserFenceBg = String(useThemeColor("--color-md-user-fence-bg")); | ||
| 436 | const markdownUserFenceText = String(useThemeColor("--color-md-user-fence-text")); | ||
| 464 | const iconSubtleColor = String(useThemeColor("--color-icon-subtle")); | 437 | const iconSubtleColor = String(useThemeColor("--color-icon-subtle")); |
| 465 | const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); | 438 | const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); |
| 439 | const userBubbleSkillForeground = String(useThemeColor("--color-user-bubble-skill-foreground")); | ||
| 466 | const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted")); | 440 | const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted")); |
| 467 | const regularFontFamily = useFontFamily("regular"); | 441 | const regularFontFamily = useFontFamily("regular"); |
| 468 | const boldFontFamily = useFontFamily("bold"); | 442 | const boldFontFamily = useFontFamily("bold"); |
| 469 | 443 | ||
| 470 | return useMemo(() => { | 444 | return useMemo(() => { |
| 471 | const markdownBodyColor = colors.body; | ||
| 472 | const markdownStrongColor = colors.strong; | ||
| 473 | const markdownLinkColor = colors.link; | ||
| 474 | const markdownBlockquoteBg = colors.blockquoteBackground; | ||
| 475 | const markdownBlockquoteBorder = colors.blockquoteBorder; | ||
| 476 | const markdownCodeBg = colors.codeBackground; | ||
| 477 | const markdownCodeText = colors.codeText; | ||
| 478 | const markdownInlineCodeText = colors.inlineCodeText; | ||
| 479 | const markdownHrColor = colors.horizontalRule; | ||
| 480 | const markdownUserBodyColor = colors.userBody; | ||
| 481 | const markdownUserCodeBg = colors.userCodeBackground; | ||
| 482 | const markdownUserCodeText = colors.userCodeText; | ||
| 483 | const markdownUserInlineCodeText = colors.userInlineCodeText; | ||
| 484 | const markdownUserFenceBg = colors.userFenceBackground; | ||
| 485 | const markdownUserFenceText = colors.userFenceText; | ||
| 486 | |||
| 487 | const baseTheme: PartialMarkdownTheme = { | 445 | const baseTheme: PartialMarkdownTheme = { |
| 488 | colors: { | 446 | colors: { |
| 489 | text: markdownBodyColor, | 447 | text: markdownBodyColor, |
| ... | @@ -759,8 +717,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -759,8 +717,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 759 | codeColor: markdownUserCodeText, | 717 | codeColor: markdownUserCodeText, |
| 760 | codeBackgroundColor: markdownUserCodeBg, | 718 | codeBackgroundColor: markdownUserCodeBg, |
| 761 | codeBlockBackgroundColor: markdownUserFenceBg, | 719 | codeBlockBackgroundColor: markdownUserFenceBg, |
| 762 | fileTextColor: "#ffffff", | 720 | fileTextColor: markdownUserBodyColor, |
| 763 | skillTextColor: "#f0abfc", | 721 | skillTextColor: userBubbleSkillForeground, |
| 764 | quoteMarkerColor: markdownUserBodyColor, | 722 | quoteMarkerColor: markdownUserBodyColor, |
| 765 | dividerColor: markdownUserBodyColor, | 723 | dividerColor: markdownUserBodyColor, |
| 766 | fontSize: nativeMarkdownTypography.fontSize, | 724 | fontSize: nativeMarkdownTypography.fontSize, |
| ... | @@ -807,15 +765,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | ... | @@ -807,15 +765,30 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 807 | }; | 765 | }; |
| 808 | }, [ | 766 | }, [ |
| 809 | boldFontFamily, | 767 | boldFontFamily, |
| 810 | colors, | ||
| 811 | iconSubtleColor, | 768 | iconSubtleColor, |
| 812 | inlineSkillForeground, | 769 | inlineSkillForeground, |
| 770 | markdownBlockquoteBg, | ||
| 771 | markdownBlockquoteBorder, | ||
| 772 | markdownBodyColor, | ||
| 773 | markdownCodeBg, | ||
| 774 | markdownCodeText, | ||
| 813 | markdownFontSizes, | 775 | markdownFontSizes, |
| 776 | markdownHrColor, | ||
| 777 | markdownInlineCodeText, | ||
| 778 | markdownLinkColor, | ||
| 779 | markdownStrongColor, | ||
| 780 | markdownUserBodyColor, | ||
| 781 | markdownUserCodeBg, | ||
| 782 | markdownUserCodeText, | ||
| 783 | markdownUserFenceBg, | ||
| 784 | markdownUserFenceText, | ||
| 785 | markdownUserInlineCodeText, | ||
| 814 | nativeMarkdownTypography, | 786 | nativeMarkdownTypography, |
| 815 | onLinkPress, | 787 | onLinkPress, |
| 816 | regularFontFamily, | 788 | regularFontFamily, |
| 817 | themeMode, | 789 | themeMode, |
| 818 | userBubbleForegroundMuted, | 790 | userBubbleForegroundMuted, |
| 791 | userBubbleSkillForeground, | ||
| 819 | ]); | 792 | ]); |
| 820 | } | 793 | } |
| 821 | 794 | ||
| ... | @@ -1141,8 +1114,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { | ... | @@ -1141,8 +1114,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { |
| 1141 | readonly colors: ReviewCommentColors; | 1114 | readonly colors: ReviewCommentColors; |
| 1142 | }) { | 1115 | }) { |
| 1143 | const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface(); | 1116 | const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface(); |
| 1144 | const colorScheme = useColorScheme(); | 1117 | const { themeAppearance: appearanceScheme, themeId } = useAppearancePreferences(); |
| 1145 | const appearanceScheme = colorScheme === "light" ? "light" : "dark"; | ||
| 1146 | const NativeReviewDiffView = resolveNativeReviewDiffView(); | 1118 | const NativeReviewDiffView = resolveNativeReviewDiffView(); |
| 1147 | const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]); | 1119 | const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]); |
| 1148 | const parsedDiff = useMemo( | 1120 | const parsedDiff = useMemo( |
| ... | @@ -1155,8 +1127,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { | ... | @@ -1155,8 +1127,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { |
| 1155 | [nativeReviewDiffData.rows], | 1127 | [nativeReviewDiffData.rows], |
| 1156 | ); | 1128 | ); |
| 1157 | const nativeReviewDiffTheme = useMemo( | 1129 | const nativeReviewDiffTheme = useMemo( |
| 1158 | () => createNativeReviewDiffTheme(appearanceScheme), | 1130 | () => createNativeReviewDiffTheme(appearanceScheme, themeId), |
| 1159 | [appearanceScheme], | 1131 | [appearanceScheme, themeId], |
| 1160 | ); | 1132 | ); |
| 1161 | const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); | 1133 | const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); |
| 1162 | const nativeThemeJson = useMemo( | 1134 | const nativeThemeJson = useMemo( |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+6-5| ... | @@ -15,7 +15,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; | ... | @@ -15,7 +15,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; |
| 15 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; | 15 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; |
| 16 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 16 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 17 | import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; | 17 | import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; |
| 18 | import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native"; | 18 | import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; |
| 19 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 19 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 20 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 20 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 21 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 21 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | @@ -38,6 +38,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | ... | @@ -38,6 +38,7 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| 38 | import { useWorkspaceState } from "../../state/workspace"; | 38 | import { useWorkspaceState } from "../../state/workspace"; |
| 39 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 39 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 40 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 40 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 41 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 41 | import { | 42 | import { |
| 42 | hasCustomHomeListOptions, | 43 | hasCustomHomeListOptions, |
| 43 | PROJECT_SORT_OPTIONS, | 44 | PROJECT_SORT_OPTIONS, |
| ... | @@ -102,6 +103,8 @@ function SidebarHeaderButtonGroup(props: { | ... | @@ -102,6 +103,8 @@ function SidebarHeaderButtonGroup(props: { |
| 102 | readonly children: ReactNode; | 103 | readonly children: ReactNode; |
| 103 | readonly colorScheme: "light" | "dark"; | 104 | readonly colorScheme: "light" | "dark"; |
| 104 | }) { | 105 | }) { |
| 106 | const fallbackBackground = useThemeColor("--color-glass-surface"); | ||
| 107 | const fallbackBorder = useThemeColor("--color-header-border"); | ||
| 105 | if (isLiquidGlassSupported) { | 108 | if (isLiquidGlassSupported) { |
| 106 | return ( | 109 | return ( |
| 107 | <LiquidGlassView | 110 | <LiquidGlassView |
| ... | @@ -119,9 +122,7 @@ function SidebarHeaderButtonGroup(props: { | ... | @@ -119,9 +122,7 @@ function SidebarHeaderButtonGroup(props: { |
| 119 | <View | 122 | <View |
| 120 | style={[ | 123 | style={[ |
| 121 | styles.headerButtonGroup, | 124 | styles.headerButtonGroup, |
| 122 | props.colorScheme === "dark" | 125 | { backgroundColor: fallbackBackground, borderColor: fallbackBorder }, |
| 123 | ? { backgroundColor: "rgba(118,118,128,0.24)", borderColor: "rgba(255,255,255,0.08)" } | ||
| 124 | : { backgroundColor: "rgba(255,255,255,0.72)", borderColor: "rgba(0,0,0,0.08)" }, | ||
| 125 | { borderWidth: StyleSheet.hairlineWidth }, | 126 | { borderWidth: StyleSheet.hairlineWidth }, |
| 126 | ]} | 127 | ]} |
| 127 | > | 128 | > |
| ... | @@ -192,7 +193,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -192,7 +193,7 @@ function ThreadNavigationSidebarPane( |
| 192 | props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, | 193 | props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, |
| 193 | ) { | 194 | ) { |
| 194 | const insets = useSafeAreaInsets(); | 195 | const insets = useSafeAreaInsets(); |
| 195 | const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; | 196 | const { themeAppearance: colorScheme } = useAppearancePreferences(); |
| 196 | const projects = useProjects(); | 197 | const projects = useProjects(); |
| 197 | const threads = useThreadShells(); | 198 | const threads = useThreadShells(); |
| 198 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); | 199 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+6-14| ... | @@ -27,7 +27,7 @@ import { | ... | @@ -27,7 +27,7 @@ import { |
| 27 | useState, | 27 | useState, |
| 28 | type ReactNode, | 28 | type ReactNode, |
| 29 | } from "react"; | 29 | } from "react"; |
| 30 | import { Platform, Pressable, ScrollView, Switch, TextInput, View } from "react-native"; | 30 | import { Platform, Pressable, ScrollView, TextInput, View } from "react-native"; |
| 31 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; | 31 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 32 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 32 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 33 | 33 | ||
| ... | @@ -35,6 +35,7 @@ import { SymbolView } from "../../components/AppSymbol"; | ... | @@ -35,6 +35,7 @@ import { SymbolView } from "../../components/AppSymbol"; |
| 35 | import { AppText as Text } from "../../components/AppText"; | 35 | import { AppText as Text } from "../../components/AppText"; |
| 36 | import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; | 36 | import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; |
| 37 | import { ProviderIcon } from "../../components/ProviderIcon"; | 37 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 38 | import { ThemedSwitch } from "../../components/ThemedSwitch"; | ||
| 38 | import { cn } from "../../lib/cn"; | 39 | import { cn } from "../../lib/cn"; |
| 39 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | 40 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 40 | import { applyProviderOptionSelection } from "../../lib/providerOptions"; | 41 | import { applyProviderOptionSelection } from "../../lib/providerOptions"; |
| ... | @@ -46,10 +47,6 @@ import { | ... | @@ -46,10 +47,6 @@ import { |
| 46 | nativeHeaderScrollEdgeEffects, | 47 | nativeHeaderScrollEdgeEffects, |
| 47 | } from "../../native/StackHeader"; | 48 | } from "../../native/StackHeader"; |
| 48 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 49 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 49 | import { | ||
| 50 | NATIVE_SHEET_SURFACE_COLOR, | ||
| 51 | NATIVE_SHEET_SURFACE_CONTENT_STYLE, | ||
| 52 | } from "../../native/sheet-surface"; | ||
| 53 | import { useNewTaskFlow } from "./new-task-flow-provider"; | 50 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 54 | import { | 51 | import { |
| 55 | createNativeMailSearchToolbarItem, | 52 | createNativeMailSearchToolbarItem, |
| ... | @@ -270,7 +267,7 @@ function SwitchRow(props: { | ... | @@ -270,7 +267,7 @@ function SwitchRow(props: { |
| 270 | )} | 267 | )} |
| 271 | > | 268 | > |
| 272 | <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> | 269 | <Text className="text-sm font-t3-medium text-foreground">{props.label}</Text> |
| 273 | <Switch | 270 | <ThemedSwitch |
| 274 | accessibilityLabel={props.label} | 271 | accessibilityLabel={props.label} |
| 275 | onValueChange={props.onValueChange} | 272 | onValueChange={props.onValueChange} |
| 276 | value={props.value} | 273 | value={props.value} |
| ... | @@ -1128,9 +1125,8 @@ function ThreadSettingsChoiceScreen() { | ... | @@ -1128,9 +1125,8 @@ function ThreadSettingsChoiceScreen() { |
| 1128 | } | 1125 | } |
| 1129 | 1126 | ||
| 1130 | function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) { | 1127 | function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) { |
| 1131 | const sheetBackground = String(useThemeColor("--color-sheet")); | 1128 | const solidSheetBackground = String(useThemeColor("--color-sheet-solid")); |
| 1132 | const foreground = String(useThemeColor("--color-foreground")); | 1129 | const foreground = String(useThemeColor("--color-foreground")); |
| 1133 | const nativeSheetBackground = NATIVE_SHEET_SURFACE_COLOR ?? sheetBackground; | ||
| 1134 | const presentation = useMemo( | 1130 | const presentation = useMemo( |
| 1135 | () => ({ | 1131 | () => ({ |
| 1136 | onClose: props.onClose, | 1132 | onClose: props.onClose, |
| ... | @@ -1144,17 +1140,13 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) | ... | @@ -1144,17 +1140,13 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) |
| 1144 | initialRouteName="ThreadSettingsModels" | 1140 | initialRouteName="ThreadSettingsModels" |
| 1145 | screenOptions={{ | 1141 | screenOptions={{ |
| 1146 | animation: "slide_from_right", | 1142 | animation: "slide_from_right", |
| 1147 | contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE ?? { | 1143 | contentStyle: { backgroundColor: solidSheetBackground }, |
| 1148 | backgroundColor: nativeSheetBackground, | ||
| 1149 | }, | ||
| 1150 | gestureEnabled: true, | 1144 | gestureEnabled: true, |
| 1151 | headerBackButtonDisplayMode: "minimal", | 1145 | headerBackButtonDisplayMode: "minimal", |
| 1152 | headerBackTitle: "", | 1146 | headerBackTitle: "", |
| 1153 | headerShadowVisible: false, | 1147 | headerShadowVisible: false, |
| 1154 | headerStyle: { | 1148 | headerStyle: { |
| 1155 | backgroundColor: (NATIVE_LIQUID_GLASS_SUPPORTED | 1149 | backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : solidSheetBackground, |
| 1156 | ? "transparent" | ||
| 1157 | : nativeSheetBackground) as unknown as string, | ||
| 1158 | }, | 1150 | }, |
| 1159 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, | 1151 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, |
| 1160 | headerTintColor: foreground, | 1152 | headerTintColor: foreground, |
apps/mobile/src/features/threads/sidebar-filter-button.tsx+3-5| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { SymbolView } from "../../components/AppSymbol"; | 1 | import { SymbolView } from "../../components/AppSymbol"; |
| 2 | import { Pressable, StyleSheet, useColorScheme } from "react-native"; | 2 | import { Pressable, StyleSheet } from "react-native"; |
| 3 | 3 | ||
| 4 | import { useThemeColor } from "../../lib/useThemeColor"; | 4 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 5 | 5 | ||
| ... | @@ -15,10 +15,8 @@ export function SidebarFilterButton(props: { | ... | @@ -15,10 +15,8 @@ export function SidebarFilterButton(props: { |
| 15 | }) { | 15 | }) { |
| 16 | const iconColor = useThemeColor("--color-foreground"); | 16 | const iconColor = useThemeColor("--color-foreground"); |
| 17 | const pressedBackgroundColor = useThemeColor("--color-subtle"); | 17 | const pressedBackgroundColor = useThemeColor("--color-subtle"); |
| 18 | const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; | 18 | const idleBackgroundColor = useThemeColor("--color-glass-surface"); |
| 19 | const idleBackgroundColor = | 19 | const borderColor = useThemeColor("--color-header-border"); |
| 20 | colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)"; | ||
| 21 | const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; | ||
| 22 | 20 | ||
| 23 | return ( | 21 | return ( |
| 24 | <Pressable | 22 | <Pressable |
apps/mobile/src/features/threads/sidebar-header-actions.tsx+3-5| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { SymbolView } from "../../components/AppSymbol"; | 1 | import { SymbolView } from "../../components/AppSymbol"; |
| 2 | import { Pressable, StyleSheet, View, useColorScheme } from "react-native"; | 2 | import { Pressable, StyleSheet, View } from "react-native"; |
| 3 | 3 | ||
| 4 | import { useThemeColor } from "../../lib/useThemeColor"; | 4 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 5 | 5 | ||
| ... | @@ -17,10 +17,8 @@ function FallbackHeaderButton(props: { | ... | @@ -17,10 +17,8 @@ function FallbackHeaderButton(props: { |
| 17 | }) { | 17 | }) { |
| 18 | const iconColor = useThemeColor("--color-foreground"); | 18 | const iconColor = useThemeColor("--color-foreground"); |
| 19 | const pressedBackgroundColor = useThemeColor("--color-subtle"); | 19 | const pressedBackgroundColor = useThemeColor("--color-subtle"); |
| 20 | const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; | 20 | const idleBackgroundColor = useThemeColor("--color-glass-surface"); |
| 21 | const idleBackgroundColor = | 21 | const borderColor = useThemeColor("--color-header-border"); |
| 22 | colorScheme === "dark" ? "rgba(118,118,128,0.24)" : "rgba(255,255,255,0.72)"; | ||
| 23 | const borderColor = colorScheme === "dark" ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"; | ||
| 24 | 22 | ||
| 25 | return ( | 23 | return ( |
| 26 | <Pressable | 24 | <Pressable |
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+7-9| ... | @@ -1,19 +1,16 @@ | ... | @@ -1,19 +1,16 @@ |
| 1 | import { | 1 | import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; |
| 2 | DarkTheme, | ||
| 3 | DefaultTheme, | ||
| 4 | NavigationContainer, | ||
| 5 | NavigationIndependentTree, | ||
| 6 | } from "@react-navigation/native"; | ||
| 7 | import { | 2 | import { |
| 8 | createNativeStackNavigator, | 3 | createNativeStackNavigator, |
| 9 | type NativeStackNavigationOptions, | 4 | type NativeStackNavigationOptions, |
| 10 | } from "@react-navigation/native-stack"; | 5 | } from "@react-navigation/native-stack"; |
| 11 | import type { ReactNode } from "react"; | 6 | import type { ReactNode } from "react"; |
| 12 | import { Platform, useColorScheme } from "react-native"; | 7 | import { Platform } from "react-native"; |
| 13 | 8 | ||
| 14 | import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; | 9 | import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; |
| 15 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 10 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 16 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; | 11 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 12 | import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; | ||
| 13 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 17 | 14 | ||
| 18 | const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); | 15 | const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 19 | 16 | ||
| ... | @@ -56,11 +53,12 @@ const SidebarStack = createNativeStackNavigator(); | ... | @@ -56,11 +53,12 @@ const SidebarStack = createNativeStackNavigator(); |
| 56 | * navigation hooks used for header configuration inside the pane. | 53 | * navigation hooks used for header configuration inside the pane. |
| 57 | */ | 54 | */ |
| 58 | export function SidebarNavigationShell(props: { readonly children: ReactNode }) { | 55 | export function SidebarNavigationShell(props: { readonly children: ReactNode }) { |
| 59 | const colorScheme = useColorScheme(); | 56 | const { themeAppearance } = useAppearancePreferences(); |
| 57 | const navigationTheme = useMobileNavigationTheme(themeAppearance); | ||
| 60 | 58 | ||
| 61 | return ( | 59 | return ( |
| 62 | <NavigationIndependentTree> | 60 | <NavigationIndependentTree> |
| 63 | <NavigationContainer theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}> | 61 | <NavigationContainer theme={navigationTheme}> |
| 64 | <SidebarStack.Navigator | 62 | <SidebarStack.Navigator |
| 65 | screenOptions={SIDEBAR_SCREEN_OPTIONS} | 63 | screenOptions={SIDEBAR_SCREEN_OPTIONS} |
| 66 | initialRouteName="SidebarThreads" | 64 | initialRouteName="SidebarThreads" |
apps/mobile/src/features/threads/thread-list-items.tsx+20-10| ... | @@ -7,8 +7,9 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state | ... | @@ -7,8 +7,9 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state |
| 7 | import type { MenuAction } from "@react-native-menu/menu"; | 7 | import type { MenuAction } from "@react-native-menu/menu"; |
| 8 | import { SymbolView } from "../../components/AppSymbol"; | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 9 | import { memo, useCallback, useMemo, type ComponentProps } from "react"; | 9 | import { memo, useCallback, useMemo, type ComponentProps } from "react"; |
| 10 | import { Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; | 10 | import { Pressable, useWindowDimensions, View } from "react-native"; |
| 11 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 11 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 12 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 12 | import Svg, { Circle, Path } from "react-native-svg"; | 13 | import Svg, { Circle, Path } from "react-native-svg"; |
| 13 | 14 | ||
| 14 | import { AppText as Text } from "../../components/AppText"; | 15 | import { AppText as Text } from "../../components/AppText"; |
| ... | @@ -17,6 +18,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; | ... | @@ -17,6 +18,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 17 | import { cn } from "../../lib/cn"; | 18 | import { cn } from "../../lib/cn"; |
| 18 | import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; | 19 | import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; |
| 19 | import { relativeTime } from "../../lib/time"; | 20 | import { relativeTime } from "../../lib/time"; |
| 21 | import { themeColorWithAlpha } from "../../lib/mobileTheme"; | ||
| 20 | import { useThemeColor } from "../../lib/useThemeColor"; | 22 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 21 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 23 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 22 | import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; | 24 | import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; |
| ... | @@ -38,10 +40,7 @@ export type ThreadListVariant = "compact" | "sidebar"; | ... | @@ -38,10 +40,7 @@ export type ThreadListVariant = "compact" | "sidebar"; |
| 38 | export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; | 40 | export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; |
| 39 | const SIDEBAR_ROW_RADIUS = 12; | 41 | const SIDEBAR_ROW_RADIUS = 12; |
| 40 | 42 | ||
| 41 | function pullRequestTintColor( | 43 | function pullRequestTintColor(state: ThreadPr["state"], colorScheme: "light" | "dark") { |
| 42 | state: ThreadPr["state"], | ||
| 43 | colorScheme: ReturnType<typeof useColorScheme>, | ||
| 44 | ) { | ||
| 45 | const dark = colorScheme === "dark"; | 44 | const dark = colorScheme === "dark"; |
| 46 | switch (state) { | 45 | switch (state) { |
| 47 | case "open": | 46 | case "open": |
| ... | @@ -440,7 +439,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -440,7 +439,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 440 | >["simultaneousWithExternalGesture"]; | 439 | >["simultaneousWithExternalGesture"]; |
| 441 | }) { | 440 | }) { |
| 442 | const { width: windowWidth } = useWindowDimensions(); | 441 | const { width: windowWidth } = useWindowDimensions(); |
| 443 | const colorScheme = useColorScheme(); | 442 | const { themeAppearance: colorScheme } = useAppearancePreferences(); |
| 444 | const compact = props.variant === "compact"; | 443 | const compact = props.variant === "compact"; |
| 445 | const selected = props.selected === true; | 444 | const selected = props.selected === true; |
| 446 | // Recycling-safe: resets when the list container is reused for another | 445 | // Recycling-safe: resets when the list container is reused for another |
| ... | @@ -453,6 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -453,6 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 453 | const drawerColor = useThemeColor("--color-drawer"); | 452 | const drawerColor = useThemeColor("--color-drawer"); |
| 454 | const pressedBackgroundColor = useThemeColor("--color-subtle"); | 453 | const pressedBackgroundColor = useThemeColor("--color-subtle"); |
| 455 | const selectedBackgroundColor = useThemeColor("--color-user-bubble"); | 454 | const selectedBackgroundColor = useThemeColor("--color-user-bubble"); |
| 455 | const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); | ||
| 456 | 456 | ||
| 457 | const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = | 457 | const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = |
| 458 | props; | 458 | props; |
| ... | @@ -467,10 +467,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -467,10 +467,16 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 467 | ); | 467 | ); |
| 468 | 468 | ||
| 469 | const backgroundColor = compact ? screenColor : drawerColor; | 469 | const backgroundColor = compact ? screenColor : drawerColor; |
| 470 | const effectivePressedBackground = selected ? "rgba(255,255,255,0.16)" : pressedBackgroundColor; | 470 | const effectivePressedBackground = selected |
| 471 | ? themeColorWithAlpha(String(selectedForegroundColor), 0.16) | ||
| 472 | : pressedBackgroundColor; | ||
| 471 | const effectiveStatus = | 473 | const effectiveStatus = |
| 472 | selected && status | 474 | selected && status |
| 473 | ? { ...status, pillClassName: "bg-white/20", textClassName: "text-white" } | 475 | ? { |
| 476 | ...status, | ||
| 477 | pillClassName: "bg-user-bubble-foreground/20", | ||
| 478 | textClassName: "text-user-bubble-foreground", | ||
| 479 | } | ||
| 474 | : status; | 480 | : status; |
| 475 | 481 | ||
| 476 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); | 482 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| ... | @@ -538,11 +544,15 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -538,11 +544,15 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 538 | <View className="flex-row items-center gap-0.5"> | 544 | <View className="flex-row items-center gap-0.5"> |
| 539 | <PullRequestIcon | 545 | <PullRequestIcon |
| 540 | size={compact ? 13 : 11} | 546 | size={compact ? 13 : 11} |
| 541 | color={selected ? "#ffffff" : pullRequestTintColor(pr.state, colorScheme)} | 547 | color={ |
| 548 | selected | ||
| 549 | ? String(selectedForegroundColor) | ||
| 550 | : pullRequestTintColor(pr.state, colorScheme) | ||
| 551 | } | ||
| 542 | /> | 552 | /> |
| 543 | <Text | 553 | <Text |
| 544 | className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${ | 554 | className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${ |
| 545 | selected ? "text-white" : pr.textClassName | 555 | selected ? "text-user-bubble-foreground" : pr.textClassName |
| 546 | }`} | 556 | }`} |
| 547 | > | 557 | > |
| 548 | {pr.label} | 558 | {pr.label} |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+9-13| ... | @@ -6,14 +6,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state | ... | @@ -6,14 +6,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state |
| 6 | import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; | 6 | import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; |
| 7 | import type { MenuAction } from "@react-native-menu/menu"; | 7 | import type { MenuAction } from "@react-native-menu/menu"; |
| 8 | import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; | 8 | import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; |
| 9 | import { | 9 | import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; |
| 10 | Alert, | ||
| 11 | Platform, | ||
| 12 | Pressable, | ||
| 13 | useColorScheme, | ||
| 14 | useWindowDimensions, | ||
| 15 | View, | ||
| 16 | } from "react-native"; | ||
| 17 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 10 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 18 | 11 | ||
| 19 | import { SymbolView } from "../../components/AppSymbol"; | 12 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | @@ -27,6 +20,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -27,6 +20,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 27 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 20 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 28 | import { useThreadPr } from "../../state/use-thread-pr"; | 21 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 29 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | 22 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 23 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 30 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | 24 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; |
| 31 | import { | 25 | import { |
| 32 | resolveThreadListV2SnoozeMenuSelection, | 26 | resolveThreadListV2SnoozeMenuSelection, |
| ... | @@ -120,7 +114,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS | ... | @@ -120,7 +114,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS |
| 120 | readonly onToggle: () => void; | 114 | readonly onToggle: () => void; |
| 121 | readonly pane?: "screen" | "sidebar"; | 115 | readonly pane?: "screen" | "sidebar"; |
| 122 | }) { | 116 | }) { |
| 123 | const colorScheme = useColorScheme(); | 117 | const { themeAppearance: colorScheme } = useAppearancePreferences(); |
| 124 | return ( | 118 | return ( |
| 125 | <Pressable | 119 | <Pressable |
| 126 | accessibilityHint={ | 120 | accessibilityHint={ |
| ... | @@ -660,8 +654,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -660,8 +654,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 660 | ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` | 654 | ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` |
| 661 | : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; | 655 | : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; |
| 662 | 656 | ||
| 663 | // The sidebar pane fills selected rows with the accent color (matching the | 657 | // The sidebar pane fills selected rows with the theme's message surface, so |
| 664 | // v1 sidebar), so every piece of row text needs a white-on-accent variant. | 658 | // every piece of row text must use that surface's paired foreground. |
| 665 | const cardContent = ( | 659 | const cardContent = ( |
| 666 | <> | 660 | <> |
| 667 | <View className="flex-row items-center gap-1.5"> | 661 | <View className="flex-row items-center gap-1.5"> |
| ... | @@ -689,7 +683,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -689,7 +683,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 689 | <Text | 683 | <Text |
| 690 | className={cn( | 684 | className={cn( |
| 691 | "text-xs tabular-nums", | 685 | "text-xs tabular-nums", |
| 692 | selected ? "text-white" : (statusLabel?.className ?? "text-foreground-tertiary"), | 686 | selected |
| 687 | ? "text-user-bubble-foreground" | ||
| 688 | : (statusLabel?.className ?? "text-foreground-tertiary"), | ||
| 693 | )} | 689 | )} |
| 694 | > | 690 | > |
| 695 | {statusLabel?.label ?? timeLabel} | 691 | {statusLabel?.label ?? timeLabel} |
| ... | @@ -766,7 +762,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -766,7 +762,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 766 | {pr ? ( | 762 | {pr ? ( |
| 767 | <Text | 763 | <Text |
| 768 | accessibilityLabel={pr.accessibilityLabel} | 764 | accessibilityLabel={pr.accessibilityLabel} |
| 769 | className={cn("text-xs", selected ? "text-white" : pr.textClassName)} | 765 | className={cn("text-xs", selected ? "text-user-bubble-foreground" : pr.textClassName)} |
| 770 | style={{ fontFamily: MONO_FONT }} | 766 | style={{ fontFamily: MONO_FONT }} |
| 771 | > | 767 | > |
| 772 | #{pr.label} | 768 | #{pr.label} |
apps/mobile/src/features/threads/thread-work-log.tsx+4-5| ... | @@ -1,12 +1,13 @@ | ... | @@ -1,12 +1,13 @@ |
| 1 | import * as Haptics from "expo-haptics"; | 1 | import * as Haptics from "expo-haptics"; |
| 2 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; | 2 | import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; |
| 3 | import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native"; | 3 | import { LayoutAnimation, Pressable, ScrollView, View } from "react-native"; |
| 4 | 4 | ||
| 5 | import { AppText as Text } from "../../components/AppText"; | 5 | import { AppText as Text } from "../../components/AppText"; |
| 6 | import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; | 6 | import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; |
| 7 | import { cn } from "../../lib/cn"; | 7 | import { cn } from "../../lib/cn"; |
| 8 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; | 8 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; |
| 9 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 9 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 10 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 10 | import Animated, { FadeIn } from "react-native-reanimated"; | 11 | import Animated, { FadeIn } from "react-native-reanimated"; |
| 11 | 12 | ||
| 12 | const WORK_LOG_LAYOUT_ANIMATION = { | 13 | const WORK_LOG_LAYOUT_ANIMATION = { |
| ... | @@ -127,8 +128,7 @@ export function ThreadWorkLog(props: { | ... | @@ -127,8 +128,7 @@ export function ThreadWorkLog(props: { |
| 127 | readonly onCopyRow: (rowId: string, value: string) => void; | 128 | readonly onCopyRow: (rowId: string, value: string) => void; |
| 128 | readonly onToggleRow: (rowId: string) => void; | 129 | readonly onToggleRow: (rowId: string) => void; |
| 129 | }) { | 130 | }) { |
| 130 | const colorScheme = useColorScheme(); | 131 | const pressedBackground = useThemeColor("--color-subtle"); |
| 131 | const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; | ||
| 132 | const rows = visibleWorkLogActivities(props.activities).map((activity) => ({ | 132 | const rows = visibleWorkLogActivities(props.activities).map((activity) => ({ |
| 133 | ...activity, | 133 | ...activity, |
| 134 | detail: compactActivityDetail(activity.detail), | 134 | detail: compactActivityDetail(activity.detail), |
| ... | @@ -281,8 +281,7 @@ export function ThreadWorkGroupToggle(props: { | ... | @@ -281,8 +281,7 @@ export function ThreadWorkGroupToggle(props: { |
| 281 | readonly onlyToolActivities: boolean; | 281 | readonly onlyToolActivities: boolean; |
| 282 | readonly onToggle: () => void; | 282 | readonly onToggle: () => void; |
| 283 | }) { | 283 | }) { |
| 284 | const colorScheme = useColorScheme(); | 284 | const pressedBackground = useThemeColor("--color-subtle"); |
| 285 | const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; | ||
| 286 | const noun = props.onlyToolActivities | 285 | const noun = props.onlyToolActivities |
| 287 | ? props.hiddenCount === 1 | 286 | ? props.hiddenCount === 1 |
| 288 | ? "tool call" | 287 | ? "tool call" |
apps/mobile/src/features/usage/usageProviders.ts+2-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { UsageProviderKind } from "@t3tools/contracts"; | 1 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | import { useColorScheme } from "react-native"; | 2 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| 5 | * Series and table order. The chart stacks providers from the bottom in this | 5 | * Series and table order. The chart stacks providers from the bottom in this |
| ... | @@ -17,7 +17,7 @@ export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { | ... | @@ -17,7 +17,7 @@ export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { |
| 17 | * with the theme or its bars vanish against the matching background. | 17 | * with the theme or its bars vanish against the matching background. |
| 18 | */ | 18 | */ |
| 19 | export function useProviderColors(): Record<UsageProviderKind, string> { | 19 | export function useProviderColors(): Record<UsageProviderKind, string> { |
| 20 | const scheme = useColorScheme(); | 20 | const { themeAppearance: scheme } = useAppearancePreferences(); |
| 21 | return { | 21 | return { |
| 22 | claude: "#d97757", | 22 | claude: "#d97757", |
| 23 | codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", | 23 | codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", |
apps/mobile/src/lib/mobileDefaultTheme.ts created+139| ... | @@ -0,0 +1,139 @@ | ||
| 1 | import type { MobileThemeVariables } from "./mobileTheme"; | ||
| 2 | |||
| 3 | /** The existing T3 Code mobile palette, retained as the upgrade-safe default. */ | ||
| 4 | export const DEFAULT_MOBILE_THEME_VARIABLES = { | ||
| 5 | light: { | ||
| 6 | "--color-screen": "#f2f2f7", | ||
| 7 | "--color-sheet": "rgba(242, 242, 247, 0.98)", | ||
| 8 | "--color-sheet-solid": "#f2f2f7", | ||
| 9 | "--color-card": "#ffffff", | ||
| 10 | "--color-card-alt": "#f5f5f5", | ||
| 11 | "--color-card-translucent": "rgba(255, 255, 255, 0.8)", | ||
| 12 | "--color-foreground": "#262626", | ||
| 13 | "--color-foreground-secondary": "#525252", | ||
| 14 | "--color-foreground-muted": "#737373", | ||
| 15 | "--color-foreground-tertiary": "#8e8e93", | ||
| 16 | "--color-border": "rgba(0, 0, 0, 0.08)", | ||
| 17 | "--color-border-subtle": "rgba(0, 0, 0, 0.06)", | ||
| 18 | "--color-separator": "rgba(0, 0, 0, 0.04)", | ||
| 19 | "--color-subtle": "rgba(0, 0, 0, 0.04)", | ||
| 20 | "--color-subtle-strong": "rgba(0, 0, 0, 0.08)", | ||
| 21 | "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)", | ||
| 22 | "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)", | ||
| 23 | "--color-inline-skill-foreground": "#a21caf", | ||
| 24 | "--color-primary": "#262626", | ||
| 25 | "--color-primary-foreground": "#ffffff", | ||
| 26 | "--color-primary-shadow": "#000000", | ||
| 27 | "--color-secondary": "#ffffff", | ||
| 28 | "--color-secondary-foreground": "#262626", | ||
| 29 | "--color-secondary-border": "rgba(0, 0, 0, 0.08)", | ||
| 30 | "--color-switch-active-track": "#34c759", | ||
| 31 | "--color-switch-active-thumb": "#ffffff", | ||
| 32 | "--color-switch-inactive-track": "rgba(0, 0, 0, 0.08)", | ||
| 33 | "--color-switch-inactive-thumb": "#8e8e93", | ||
| 34 | "--color-danger": "#fef2f2", | ||
| 35 | "--color-danger-border": "rgba(239, 68, 68, 0.12)", | ||
| 36 | "--color-danger-foreground": "#dc2626", | ||
| 37 | "--color-input": "#ffffff", | ||
| 38 | "--color-input-border": "rgba(0, 0, 0, 0.1)", | ||
| 39 | "--color-sidebar-search": "rgba(118, 118, 128, 0.12)", | ||
| 40 | "--color-placeholder": "#737373", | ||
| 41 | "--color-icon": "#262626", | ||
| 42 | "--color-icon-muted": "#525252", | ||
| 43 | "--color-icon-subtle": "#a3a3a3", | ||
| 44 | "--color-header": "rgba(255, 255, 255, 0.97)", | ||
| 45 | "--color-header-border": "rgba(0, 0, 0, 0.06)", | ||
| 46 | "--color-glass-surface": "rgba(255, 255, 255, 0.72)", | ||
| 47 | "--color-glass-tint": "rgba(255, 255, 255, 0.18)", | ||
| 48 | "--color-status-bar": "#f2f2f7", | ||
| 49 | "--color-md-body": "#111111", | ||
| 50 | "--color-md-strong": "#000000", | ||
| 51 | "--color-md-link": "#2563eb", | ||
| 52 | "--color-md-blockquote-border": "rgba(0, 0, 0, 0.08)", | ||
| 53 | "--color-md-blockquote-bg": "rgba(0, 0, 0, 0.02)", | ||
| 54 | "--color-md-code-bg": "rgba(0, 0, 0, 0.04)", | ||
| 55 | "--color-md-code-text": "#262626", | ||
| 56 | "--color-md-user-code-bg": "rgba(255, 255, 255, 0.22)", | ||
| 57 | "--color-md-user-code-text": "#ffffff", | ||
| 58 | "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.16)", | ||
| 59 | "--color-md-user-fence-text": "#ffffff", | ||
| 60 | "--color-md-hr": "rgba(0, 0, 0, 0.08)", | ||
| 61 | "--color-user-bubble": "#007aff", | ||
| 62 | "--color-user-bubble-foreground": "#ffffff", | ||
| 63 | "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)", | ||
| 64 | "--color-user-bubble-skill-foreground": "#f0abfc", | ||
| 65 | "--color-backdrop": "rgba(0, 0, 0, 0.22)", | ||
| 66 | "--color-drawer": "rgba(255, 255, 255, 0.99)", | ||
| 67 | "--color-drawer-shadow": "rgba(0, 0, 0, 0.12)", | ||
| 68 | "--color-dot-separator": "rgba(0, 0, 0, 0.2)", | ||
| 69 | "--color-wordmark": "#262626", | ||
| 70 | "--color-chevron": "rgba(0, 0, 0, 0.2)", | ||
| 71 | }, | ||
| 72 | dark: { | ||
| 73 | "--color-screen": "#0a0a0a", | ||
| 74 | "--color-sheet": "rgba(14, 14, 14, 0.98)", | ||
| 75 | "--color-sheet-solid": "#0e0e0e", | ||
| 76 | "--color-card": "#171717", | ||
| 77 | "--color-card-alt": "#1c1c1c", | ||
| 78 | "--color-card-translucent": "rgba(17, 17, 17, 0.8)", | ||
| 79 | "--color-foreground": "#f5f5f5", | ||
| 80 | "--color-foreground-secondary": "#a3a3a3", | ||
| 81 | "--color-foreground-muted": "#8e8e93", | ||
| 82 | "--color-foreground-tertiary": "#636366", | ||
| 83 | "--color-border": "rgba(255, 255, 255, 0.06)", | ||
| 84 | "--color-border-subtle": "rgba(255, 255, 255, 0.04)", | ||
| 85 | "--color-separator": "rgba(255, 255, 255, 0.03)", | ||
| 86 | "--color-subtle": "rgba(255, 255, 255, 0.04)", | ||
| 87 | "--color-subtle-strong": "rgba(255, 255, 255, 0.08)", | ||
| 88 | "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)", | ||
| 89 | "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)", | ||
| 90 | "--color-inline-skill-foreground": "#f0abfc", | ||
| 91 | "--color-primary": "#f5f5f5", | ||
| 92 | "--color-primary-foreground": "#0a0a0a", | ||
| 93 | "--color-primary-shadow": "#000000", | ||
| 94 | "--color-secondary": "rgba(255, 255, 255, 0.04)", | ||
| 95 | "--color-secondary-foreground": "#f5f5f5", | ||
| 96 | "--color-secondary-border": "rgba(255, 255, 255, 0.06)", | ||
| 97 | "--color-switch-active-track": "#30d158", | ||
| 98 | "--color-switch-active-thumb": "#ffffff", | ||
| 99 | "--color-switch-inactive-track": "rgba(255, 255, 255, 0.06)", | ||
| 100 | "--color-switch-inactive-thumb": "#8e8e93", | ||
| 101 | "--color-danger": "rgba(239, 68, 68, 0.14)", | ||
| 102 | "--color-danger-border": "rgba(248, 113, 113, 0.18)", | ||
| 103 | "--color-danger-foreground": "#fca5a5", | ||
| 104 | "--color-input": "#141414", | ||
| 105 | "--color-input-border": "rgba(255, 255, 255, 0.08)", | ||
| 106 | "--color-sidebar-search": "rgba(118, 118, 128, 0.24)", | ||
| 107 | "--color-placeholder": "#8e8e93", | ||
| 108 | "--color-icon": "#f5f5f5", | ||
| 109 | "--color-icon-muted": "#a3a3a3", | ||
| 110 | "--color-icon-subtle": "#8e8e93", | ||
| 111 | "--color-header": "rgba(10, 10, 10, 0.97)", | ||
| 112 | "--color-header-border": "rgba(255, 255, 255, 0.06)", | ||
| 113 | "--color-glass-surface": "rgba(23, 23, 23, 0.78)", | ||
| 114 | "--color-glass-tint": "rgba(23, 23, 23, 0.24)", | ||
| 115 | "--color-status-bar": "#0a0a0a", | ||
| 116 | "--color-md-body": "#e5e5e5", | ||
| 117 | "--color-md-strong": "#f5f5f5", | ||
| 118 | "--color-md-link": "#60a5fa", | ||
| 119 | "--color-md-blockquote-border": "rgba(255, 255, 255, 0.1)", | ||
| 120 | "--color-md-blockquote-bg": "rgba(255, 255, 255, 0.03)", | ||
| 121 | "--color-md-code-bg": "rgba(255, 255, 255, 0.06)", | ||
| 122 | "--color-md-code-text": "#e5e5e5", | ||
| 123 | "--color-md-user-code-bg": "rgba(255, 255, 255, 0.18)", | ||
| 124 | "--color-md-user-code-text": "#ffffff", | ||
| 125 | "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.28)", | ||
| 126 | "--color-md-user-fence-text": "#ffffff", | ||
| 127 | "--color-md-hr": "rgba(255, 255, 255, 0.08)", | ||
| 128 | "--color-user-bubble": "#0a84ff", | ||
| 129 | "--color-user-bubble-foreground": "#ffffff", | ||
| 130 | "--color-user-bubble-foreground-muted": "rgba(255, 255, 255, 0.78)", | ||
| 131 | "--color-user-bubble-skill-foreground": "#f0abfc", | ||
| 132 | "--color-backdrop": "rgba(0, 0, 0, 0.48)", | ||
| 133 | "--color-drawer": "rgba(14, 14, 14, 0.99)", | ||
| 134 | "--color-drawer-shadow": "rgba(0, 0, 0, 0.32)", | ||
| 135 | "--color-dot-separator": "rgba(255, 255, 255, 0.2)", | ||
| 136 | "--color-wordmark": "#f5f5f5", | ||
| 137 | "--color-chevron": "rgba(255, 255, 255, 0.2)", | ||
| 138 | }, | ||
| 139 | } as const satisfies Readonly<Record<"light" | "dark", MobileThemeVariables>>; | ||
apps/mobile/src/lib/mobileTheme.test.ts created+231| ... | @@ -0,0 +1,231 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | |||
| 4 | import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; | ||
| 5 | import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | createMobileThemePairPatch, | ||
| 9 | createMobileThemeSelectionPatch, | ||
| 10 | createMobileThemeVariables, | ||
| 11 | DEFAULT_MOBILE_THEME_ID, | ||
| 12 | getMobileThemePreviewColors, | ||
| 13 | getMobileThemeVariables, | ||
| 14 | MOBILE_THEME_IDS, | ||
| 15 | normalizeMobileThemeId, | ||
| 16 | normalizeMobileThemeMode, | ||
| 17 | resolveMobileThemeIds, | ||
| 18 | themeColorWithAlpha, | ||
| 19 | themeColorToNativeColor, | ||
| 20 | } from "./mobileTheme"; | ||
| 21 | |||
| 22 | function relativeLuminance(hex: string): number { | ||
| 23 | const channels = hex | ||
| 24 | .slice(1) | ||
| 25 | .match(/.{2}/g)! | ||
| 26 | .map((channel) => Number.parseInt(channel, 16) / 255) | ||
| 27 | .map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)); | ||
| 28 | return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!; | ||
| 29 | } | ||
| 30 | |||
| 31 | function contrastRatio(first: string, second: string): number { | ||
| 32 | const firstLuminance = relativeLuminance(first); | ||
| 33 | const secondLuminance = relativeLuminance(second); | ||
| 34 | return ( | ||
| 35 | (Math.max(firstLuminance, secondLuminance) + 0.05) / | ||
| 36 | (Math.min(firstLuminance, secondLuminance) + 0.05) | ||
| 37 | ); | ||
| 38 | } | ||
| 39 | |||
| 40 | function compositeOver(overlay: string, background: string): string { | ||
| 41 | const overlayMatch = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(overlay)!; | ||
| 42 | const backgroundChannels = background | ||
| 43 | .slice(1) | ||
| 44 | .match(/.{2}/g)! | ||
| 45 | .map((channel) => Number.parseInt(channel, 16)); | ||
| 46 | const alpha = Number(overlayMatch[4]); | ||
| 47 | const channels = [1, 2, 3].map((index) => | ||
| 48 | Math.round(Number(overlayMatch[index]) * alpha + backgroundChannels[index - 1]! * (1 - alpha)), | ||
| 49 | ); | ||
| 50 | return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; | ||
| 51 | } | ||
| 52 | |||
| 53 | describe("mobile themes", () => { | ||
| 54 | it("declares every runtime theme variable in the static stylesheet", () => { | ||
| 55 | const stylesheet = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8"); | ||
| 56 | const stylesheetVariables = new Set( | ||
| 57 | Array.from(stylesheet.matchAll(/--color-[a-z0-9-]+/g), ([variable]) => variable), | ||
| 58 | ); | ||
| 59 | |||
| 60 | expect(Array.from(stylesheetVariables).sort()).toEqual( | ||
| 61 | Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort(), | ||
| 62 | ); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("shares all built-in desktop palettes", () => { | ||
| 66 | expect(BUILT_IN_THEMES.map((theme) => theme.id)).toEqual(BUILT_IN_THEME_IDS); | ||
| 67 | for (const themeId of BUILT_IN_THEME_IDS) { | ||
| 68 | expect(getMobileThemeVariables(themeId, "light")["--color-screen"]).toMatch(/^#/); | ||
| 69 | expect(getMobileThemeVariables(themeId, "dark")["--color-screen"]).toMatch(/^#/); | ||
| 70 | } | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("preserves the existing mobile palette as the default", () => { | ||
| 74 | expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")["--color-screen"]).toBe( | ||
| 75 | "#f2f2f7", | ||
| 76 | ); | ||
| 77 | expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "dark")["--color-screen"]).toBe( | ||
| 78 | "#0a0a0a", | ||
| 79 | ); | ||
| 80 | expect( | ||
| 81 | getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")[ | ||
| 82 | "--color-user-bubble-skill-foreground" | ||
| 83 | ], | ||
| 84 | ).toBe("#f0abfc"); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("applies palette overrides on top of the selected built-in theme", () => { | ||
| 88 | const variables = getMobileThemeVariables("ocean", "dark", { | ||
| 89 | "--color-primary": "#123456", | ||
| 90 | }); | ||
| 91 | |||
| 92 | expect(variables["--color-primary"]).toBe("#123456"); | ||
| 93 | expect(variables["--color-screen"]).toMatch(/^#/); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("uses the same preview roles and standard artwork as desktop", () => { | ||
| 97 | expect(getMobileThemePreviewColors(DEFAULT_MOBILE_THEME_ID, "light")).toEqual({ | ||
| 98 | canvas: "#fcfcfc", | ||
| 99 | accent: "#f4f4f5", | ||
| 100 | messageAction: "#4f46e5", | ||
| 101 | }); | ||
| 102 | const desktopOcean = BUILT_IN_THEMES.find((theme) => theme.id === "ocean")!; | ||
| 103 | expect(getMobileThemePreviewColors("ocean", "light")).toEqual({ | ||
| 104 | canvas: themeColorToNativeColor(desktopOcean.colors.canvas), | ||
| 105 | accent: themeColorToNativeColor(desktopOcean.colors.accent), | ||
| 106 | messageAction: themeColorToNativeColor(desktopOcean.colors.messageAction), | ||
| 107 | }); | ||
| 108 | }); | ||
| 109 | |||
| 110 | it("normalizes persisted theme preferences", () => { | ||
| 111 | expect(normalizeMobileThemeId("ocean")).toBe("ocean"); | ||
| 112 | expect(normalizeMobileThemeId("missing-theme")).toBe(DEFAULT_MOBILE_THEME_ID); | ||
| 113 | expect(normalizeMobileThemeMode("dark")).toBe("dark"); | ||
| 114 | expect(normalizeMobileThemeMode("sepia")).toBe("system"); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("migrates one theme choice to both appearances and preserves independent choices", () => { | ||
| 118 | expect(resolveMobileThemeIds({ themeId: "grove" })).toEqual({ | ||
| 119 | light: "grove", | ||
| 120 | dark: "grove", | ||
| 121 | }); | ||
| 122 | expect( | ||
| 123 | resolveMobileThemeIds({ themeId: "grove", lightThemeId: "iris", darkThemeId: "ocean" }), | ||
| 124 | ).toEqual({ light: "iris", dark: "ocean" }); | ||
| 125 | expect(resolveMobileThemeIds({ themeId: "grove", lightThemeId: "missing" })).toEqual({ | ||
| 126 | light: DEFAULT_MOBILE_THEME_ID, | ||
| 127 | dark: "grove", | ||
| 128 | }); | ||
| 129 | }); | ||
| 130 | |||
| 131 | it("changes either theme without switching the active appearance", () => { | ||
| 132 | const themeIds = { light: "t3-chat", dark: "grove" } as const; | ||
| 133 | expect(createMobileThemeSelectionPatch(themeIds, "light", "dark", "ocean")).toEqual({ | ||
| 134 | lightThemeId: "t3-chat", | ||
| 135 | darkThemeId: "ocean", | ||
| 136 | themeId: "t3-chat", | ||
| 137 | }); | ||
| 138 | expect(createMobileThemeSelectionPatch(themeIds, "light", "light", "iris")).toEqual({ | ||
| 139 | lightThemeId: "iris", | ||
| 140 | darkThemeId: "grove", | ||
| 141 | themeId: "iris", | ||
| 142 | }); | ||
| 143 | }); | ||
| 144 | |||
| 145 | it("changes both appearance themes from the card action", () => { | ||
| 146 | expect(createMobileThemePairPatch("ember")).toEqual({ | ||
| 147 | lightThemeId: "ember", | ||
| 148 | darkThemeId: "ember", | ||
| 149 | themeId: "ember", | ||
| 150 | }); | ||
| 151 | }); | ||
| 152 | |||
| 153 | it("converts OKLCH colors to React Native sRGB ColorValues", () => { | ||
| 154 | expect(themeColorToNativeColor("oklch(1 0 0)")).toBe("#ffffff"); | ||
| 155 | expect(themeColorToNativeColor("oklch(0 0 0)")).toBe("#000000"); | ||
| 156 | expect(themeColorToNativeColor("#123456")).toBe("#123456"); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("changes native palette color opacity for fades", () => { | ||
| 160 | expect(themeColorWithAlpha("#123456", 0)).toBe("rgba(18, 52, 86, 0)"); | ||
| 161 | expect(themeColorWithAlpha("rgba(18, 52, 86, 0.98)", 0)).toBe("rgba(18, 52, 86, 0)"); | ||
| 162 | }); | ||
| 163 | |||
| 164 | it("maps semantic palette roles onto every mobile color variable", () => { | ||
| 165 | const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); | ||
| 166 | expect(Object.keys(variables)).toHaveLength(65); | ||
| 167 | expect(variables["--color-sheet-solid"]).toBe( | ||
| 168 | themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), | ||
| 169 | ); | ||
| 170 | expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); | ||
| 171 | expect(variables["--color-primary-shadow"]).toBe("#000000"); | ||
| 172 | expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); | ||
| 173 | expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)"); | ||
| 174 | expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/); | ||
| 175 | expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort()).toEqual( | ||
| 176 | Object.keys(variables).sort(), | ||
| 177 | ); | ||
| 178 | expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.dark).sort()).toEqual( | ||
| 179 | Object.keys(variables).sort(), | ||
| 180 | ); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("keeps every built-in shadow and backdrop black-based in dark mode", () => { | ||
| 184 | for (const theme of BUILT_IN_THEMES) { | ||
| 185 | const variables = getMobileThemeVariables(normalizeMobileThemeId(theme.id), "dark"); | ||
| 186 | expect(variables["--color-primary-shadow"]).toBe("#000000"); | ||
| 187 | expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)"); | ||
| 188 | expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)"); | ||
| 189 | } | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => { | ||
| 193 | for (const themeId of MOBILE_THEME_IDS) { | ||
| 194 | for (const appearance of ["light", "dark"] as const) { | ||
| 195 | const variables = getMobileThemeVariables(themeId, appearance); | ||
| 196 | expect( | ||
| 197 | contrastRatio(variables["--color-placeholder"], variables["--color-input"]), | ||
| 198 | ).toBeGreaterThanOrEqual(4.5); | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | for (const themeId of BUILT_IN_THEME_IDS) { | ||
| 203 | for (const appearance of ["light", "dark"] as const) { | ||
| 204 | const variables = getMobileThemeVariables(themeId, appearance); | ||
| 205 | expect( | ||
| 206 | contrastRatio( | ||
| 207 | variables["--color-user-bubble-foreground"], | ||
| 208 | variables["--color-user-bubble"], | ||
| 209 | ), | ||
| 210 | ).toBeGreaterThanOrEqual(4.5); | ||
| 211 | expect( | ||
| 212 | contrastRatio( | ||
| 213 | variables["--color-user-bubble-skill-foreground"], | ||
| 214 | variables["--color-user-bubble"], | ||
| 215 | ), | ||
| 216 | ).toBeGreaterThanOrEqual(4.5); | ||
| 217 | expect(variables["--color-user-bubble-skill-foreground"]).not.toBe( | ||
| 218 | variables["--color-user-bubble-foreground"], | ||
| 219 | ); | ||
| 220 | const fenceSurface = compositeOver( | ||
| 221 | variables["--color-md-user-fence-bg"], | ||
| 222 | variables["--color-user-bubble"], | ||
| 223 | ); | ||
| 224 | expect(fenceSurface).not.toBe(variables["--color-user-bubble"]); | ||
| 225 | expect( | ||
| 226 | contrastRatio(variables["--color-md-user-fence-text"], fenceSurface), | ||
| 227 | ).toBeGreaterThanOrEqual(4.5); | ||
| 228 | } | ||
| 229 | } | ||
| 230 | }); | ||
| 231 | }); | ||
apps/mobile/src/lib/mobileTheme.ts created+314| ... | @@ -0,0 +1,314 @@ | ||
| 1 | import { | ||
| 2 | BUILT_IN_THEMES, | ||
| 3 | getThemeColorsForAppearance, | ||
| 4 | MOBILE_DEFAULT_THEME_ID, | ||
| 5 | MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS, | ||
| 6 | type MobileThemeId as SharedMobileThemeId, | ||
| 7 | type ThemeAppearance, | ||
| 8 | type ThemeColors, | ||
| 9 | } from "@t3tools/shared/themePalettes"; | ||
| 10 | import { | ||
| 11 | STANDARD_THEME_PREVIEW_COLORS, | ||
| 12 | type ThemePreviewColors, | ||
| 13 | } from "@t3tools/shared/themePreview"; | ||
| 14 | import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme"; | ||
| 15 | |||
| 16 | export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID; | ||
| 17 | export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS; | ||
| 18 | export type MobileThemeId = SharedMobileThemeId; | ||
| 19 | export type MobileThemeAppearance = ThemeAppearance; | ||
| 20 | export type MobileThemeMode = MobileThemeAppearance | "system"; | ||
| 21 | export type MobileThemeIds = Readonly<Record<MobileThemeAppearance, MobileThemeId>>; | ||
| 22 | |||
| 23 | export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ | ||
| 24 | readonly id: MobileThemeId; | ||
| 25 | readonly label: string; | ||
| 26 | }> = [ | ||
| 27 | { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" }, | ||
| 28 | ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), | ||
| 29 | ]; | ||
| 30 | |||
| 31 | type MobileThemeVariable = `--color-${string}`; | ||
| 32 | export type MobileThemeVariables = Readonly<Record<MobileThemeVariable, string>>; | ||
| 33 | |||
| 34 | export function normalizeMobileThemeId(value: unknown): MobileThemeId { | ||
| 35 | return typeof value === "string" && (MOBILE_THEME_IDS as readonly string[]).includes(value) | ||
| 36 | ? (value as MobileThemeId) | ||
| 37 | : DEFAULT_MOBILE_THEME_ID; | ||
| 38 | } | ||
| 39 | |||
| 40 | export function normalizeMobileThemeMode(value: unknown): MobileThemeMode { | ||
| 41 | return value === "light" || value === "dark" || value === "system" ? value : "system"; | ||
| 42 | } | ||
| 43 | |||
| 44 | export function resolveMobileThemeIds(preferences: { | ||
| 45 | readonly themeId?: unknown; | ||
| 46 | readonly lightThemeId?: unknown; | ||
| 47 | readonly darkThemeId?: unknown; | ||
| 48 | }): MobileThemeIds { | ||
| 49 | const legacyThemeId = normalizeMobileThemeId(preferences.themeId); | ||
| 50 | return { | ||
| 51 | light: | ||
| 52 | preferences.lightThemeId === undefined | ||
| 53 | ? legacyThemeId | ||
| 54 | : normalizeMobileThemeId(preferences.lightThemeId), | ||
| 55 | dark: | ||
| 56 | preferences.darkThemeId === undefined | ||
| 57 | ? legacyThemeId | ||
| 58 | : normalizeMobileThemeId(preferences.darkThemeId), | ||
| 59 | }; | ||
| 60 | } | ||
| 61 | |||
| 62 | export function createMobileThemeSelectionPatch( | ||
| 63 | themeIds: MobileThemeIds, | ||
| 64 | activeAppearance: MobileThemeAppearance, | ||
| 65 | selectedAppearance: MobileThemeAppearance, | ||
| 66 | value: MobileThemeId, | ||
| 67 | ) { | ||
| 68 | const nextThemeIds: MobileThemeIds = { | ||
| 69 | light: selectedAppearance === "light" ? value : themeIds.light, | ||
| 70 | dark: selectedAppearance === "dark" ? value : themeIds.dark, | ||
| 71 | }; | ||
| 72 | return { | ||
| 73 | lightThemeId: nextThemeIds.light, | ||
| 74 | darkThemeId: nextThemeIds.dark, | ||
| 75 | // Keep older OTA bundles on the theme for the appearance currently in use. | ||
| 76 | themeId: nextThemeIds[activeAppearance], | ||
| 77 | }; | ||
| 78 | } | ||
| 79 | |||
| 80 | export function createMobileThemePairPatch(value: MobileThemeId) { | ||
| 81 | return { | ||
| 82 | lightThemeId: value, | ||
| 83 | darkThemeId: value, | ||
| 84 | themeId: value, | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | |||
| 88 | const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/; | ||
| 89 | |||
| 90 | function linearToSrgb(value: number): number { | ||
| 91 | const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055; | ||
| 92 | return Math.round(Math.min(1, Math.max(0, converted)) * 255); | ||
| 93 | } | ||
| 94 | |||
| 95 | /** React Native does not accept OKLCH ColorValues, so palettes cross the app boundary as sRGB. */ | ||
| 96 | export function themeColorToNativeColor(value: string): string { | ||
| 97 | const match = OKLCH_PATTERN.exec(value); | ||
| 98 | if (!match) return value; | ||
| 99 | |||
| 100 | const lightness = Number(match[1]); | ||
| 101 | const chroma = Number(match[2]); | ||
| 102 | const hue = (Number(match[3]) * Math.PI) / 180; | ||
| 103 | const alpha = match[4] === undefined ? 1 : Number(match[4]); | ||
| 104 | const a = chroma * Math.cos(hue); | ||
| 105 | const b = chroma * Math.sin(hue); | ||
| 106 | const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b; | ||
| 107 | const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b; | ||
| 108 | const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b; | ||
| 109 | const l = lPrime ** 3; | ||
| 110 | const m = mPrime ** 3; | ||
| 111 | const s = sPrime ** 3; | ||
| 112 | const red = linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s); | ||
| 113 | const green = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s); | ||
| 114 | const blue = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s); | ||
| 115 | |||
| 116 | return alpha < 1 | ||
| 117 | ? `rgba(${red}, ${green}, ${blue}, ${Number(alpha.toFixed(4))})` | ||
| 118 | : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; | ||
| 119 | } | ||
| 120 | |||
| 121 | function nativeColors(colors: ThemeColors): ThemeColors { | ||
| 122 | return Object.fromEntries( | ||
| 123 | Object.entries(colors).map(([role, color]) => [role, themeColorToNativeColor(color)]), | ||
| 124 | ) as ThemeColors; | ||
| 125 | } | ||
| 126 | |||
| 127 | function withAlpha(color: string, alpha: number): string { | ||
| 128 | const hex = color.startsWith("#") ? color.slice(1) : ""; | ||
| 129 | if (hex.length !== 6) return color; | ||
| 130 | const [red, green, blue] = [0, 2, 4].map((offset) => | ||
| 131 | Number.parseInt(hex.slice(offset, offset + 2), 16), | ||
| 132 | ); | ||
| 133 | return `rgba(${red}, ${green}, ${blue}, ${alpha})`; | ||
| 134 | } | ||
| 135 | |||
| 136 | function rgbChannels(color: string): readonly [number, number, number] | null { | ||
| 137 | const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); | ||
| 138 | return match | ||
| 139 | ? [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)] | ||
| 140 | : null; | ||
| 141 | } | ||
| 142 | |||
| 143 | function relativeLuminance(channels: readonly [number, number, number]): number { | ||
| 144 | const [red, green, blue] = channels.map((channel) => { | ||
| 145 | const value = channel / 255; | ||
| 146 | return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; | ||
| 147 | }); | ||
| 148 | return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!; | ||
| 149 | } | ||
| 150 | |||
| 151 | function contrastRatio( | ||
| 152 | first: readonly [number, number, number], | ||
| 153 | second: readonly [number, number, number], | ||
| 154 | ): number { | ||
| 155 | const firstLuminance = relativeLuminance(first); | ||
| 156 | const secondLuminance = relativeLuminance(second); | ||
| 157 | return ( | ||
| 158 | (Math.max(firstLuminance, secondLuminance) + 0.05) / | ||
| 159 | (Math.min(firstLuminance, secondLuminance) + 0.05) | ||
| 160 | ); | ||
| 161 | } | ||
| 162 | |||
| 163 | /** Preserve the theme's action hue while making it readable as skill text on a message bubble. */ | ||
| 164 | function readableMessageAccent(accent: string, surface: string): string { | ||
| 165 | const accentChannels = rgbChannels(accent); | ||
| 166 | const surfaceChannels = rgbChannels(surface); | ||
| 167 | if ( | ||
| 168 | !accentChannels || | ||
| 169 | !surfaceChannels || | ||
| 170 | contrastRatio(accentChannels, surfaceChannels) >= 4.5 | ||
| 171 | ) { | ||
| 172 | return accent; | ||
| 173 | } | ||
| 174 | |||
| 175 | const black = [0, 0, 0] as const; | ||
| 176 | const white = [255, 255, 255] as const; | ||
| 177 | const target = | ||
| 178 | contrastRatio(black, surfaceChannels) >= contrastRatio(white, surfaceChannels) ? black : white; | ||
| 179 | let readable: readonly [number, number, number] = target; | ||
| 180 | let lowerAmount = 0; | ||
| 181 | let upperAmount = 1; | ||
| 182 | for (let index = 0; index < 12; index += 1) { | ||
| 183 | const amount = (lowerAmount + upperAmount) / 2; | ||
| 184 | const candidate: readonly [number, number, number] = [ | ||
| 185 | Math.round(accentChannels[0] + (target[0] - accentChannels[0]) * amount), | ||
| 186 | Math.round(accentChannels[1] + (target[1] - accentChannels[1]) * amount), | ||
| 187 | Math.round(accentChannels[2] + (target[2] - accentChannels[2]) * amount), | ||
| 188 | ]; | ||
| 189 | if (contrastRatio(candidate, surfaceChannels) >= 4.5) { | ||
| 190 | readable = candidate; | ||
| 191 | upperAmount = amount; | ||
| 192 | } else { | ||
| 193 | lowerAmount = amount; | ||
| 194 | } | ||
| 195 | } | ||
| 196 | return `#${readable.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; | ||
| 197 | } | ||
| 198 | |||
| 199 | export function themeColorWithAlpha(color: string, alpha: number): string { | ||
| 200 | const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); | ||
| 201 | if (hex) { | ||
| 202 | return `rgba(${Number.parseInt(hex[1], 16)}, ${Number.parseInt(hex[2], 16)}, ${Number.parseInt(hex[3], 16)}, ${alpha})`; | ||
| 203 | } | ||
| 204 | const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/.exec(color); | ||
| 205 | return rgb ? `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})` : color; | ||
| 206 | } | ||
| 207 | |||
| 208 | export function createMobileThemeVariables( | ||
| 209 | colors: ThemeColors, | ||
| 210 | appearance: MobileThemeAppearance, | ||
| 211 | ): MobileThemeVariables { | ||
| 212 | const c = nativeColors(colors); | ||
| 213 | return { | ||
| 214 | "--color-screen": c.canvas, | ||
| 215 | "--color-sheet": withAlpha(c.chrome, 0.98), | ||
| 216 | "--color-sheet-solid": c.chrome, | ||
| 217 | "--color-card": c.surfaceRaised, | ||
| 218 | "--color-card-alt": c.surface, | ||
| 219 | "--color-card-translucent": withAlpha(c.surfaceRaised, 0.8), | ||
| 220 | "--color-foreground": c.text, | ||
| 221 | "--color-foreground-secondary": c.textMuted, | ||
| 222 | "--color-foreground-muted": c.mutedForeground, | ||
| 223 | "--color-foreground-tertiary": c.secondaryLabel, | ||
| 224 | "--color-border": c.border, | ||
| 225 | "--color-border-subtle": withAlpha(c.border, 0.7), | ||
| 226 | "--color-separator": withAlpha(c.border, 0.55), | ||
| 227 | "--color-subtle": c.muted, | ||
| 228 | "--color-subtle-strong": c.secondary, | ||
| 229 | "--color-inline-skill-background": c.accentSurface, | ||
| 230 | "--color-inline-skill-border": withAlpha(c.accent, 0.42), | ||
| 231 | "--color-inline-skill-foreground": c.accentSurfaceForeground, | ||
| 232 | "--color-primary": c.accent, | ||
| 233 | "--color-primary-foreground": c.accentForeground, | ||
| 234 | "--color-primary-shadow": "#000000", | ||
| 235 | "--color-secondary": c.secondary, | ||
| 236 | "--color-secondary-foreground": c.secondaryForeground, | ||
| 237 | "--color-secondary-border": c.border, | ||
| 238 | "--color-switch-active-track": c.accent, | ||
| 239 | "--color-switch-active-thumb": c.accentForeground, | ||
| 240 | "--color-switch-inactive-track": c.secondary, | ||
| 241 | "--color-switch-inactive-thumb": c.mutedForeground, | ||
| 242 | "--color-danger": c.errorSurface, | ||
| 243 | "--color-danger-border": withAlpha(c.error, 0.32), | ||
| 244 | "--color-danger-foreground": c.errorForeground, | ||
| 245 | "--color-input": c.surfaceRaised, | ||
| 246 | "--color-input-border": c.input, | ||
| 247 | "--color-sidebar-search": c.sidebarControlSurface, | ||
| 248 | "--color-placeholder": c.placeholder, | ||
| 249 | "--color-icon": c.text, | ||
| 250 | "--color-icon-muted": c.iconMuted, | ||
| 251 | "--color-icon-subtle": c.secondaryLabel, | ||
| 252 | "--color-header": withAlpha(c.toolbar, 0.97), | ||
| 253 | "--color-header-border": c.toolbarBorder, | ||
| 254 | "--color-glass-surface": withAlpha(c.surfaceOverlay, 0.74), | ||
| 255 | "--color-glass-tint": withAlpha(c.surfaceOverlay, 0.22), | ||
| 256 | "--color-status-bar": c.canvas, | ||
| 257 | "--color-md-body": c.text, | ||
| 258 | "--color-md-strong": c.toolbarForeground, | ||
| 259 | "--color-md-link": c.accent, | ||
| 260 | "--color-md-blockquote-border": c.border, | ||
| 261 | "--color-md-blockquote-bg": c.muted, | ||
| 262 | "--color-md-code-bg": c.codeBackground, | ||
| 263 | "--color-md-code-text": c.codeForeground, | ||
| 264 | "--color-md-user-code-bg": withAlpha(c.messageForeground, 0.18), | ||
| 265 | "--color-md-user-code-text": c.messageForeground, | ||
| 266 | "--color-md-user-fence-bg": withAlpha("#000000", appearance === "dark" ? 0.28 : 0.16), | ||
| 267 | "--color-md-user-fence-text": c.messageForeground, | ||
| 268 | "--color-md-hr": c.border, | ||
| 269 | "--color-user-bubble": c.messageSurface, | ||
| 270 | "--color-user-bubble-foreground": c.messageForeground, | ||
| 271 | "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78), | ||
| 272 | "--color-user-bubble-skill-foreground": readableMessageAccent( | ||
| 273 | c.messageAction, | ||
| 274 | c.messageSurface, | ||
| 275 | ), | ||
| 276 | "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22), | ||
| 277 | "--color-drawer": withAlpha(c.sidebar, 0.99), | ||
| 278 | "--color-drawer-shadow": withAlpha("#000000", appearance === "dark" ? 0.32 : 0.12), | ||
| 279 | "--color-dot-separator": withAlpha(c.textMuted, 0.35), | ||
| 280 | "--color-wordmark": c.text, | ||
| 281 | "--color-chevron": withAlpha(c.textMuted, 0.42), | ||
| 282 | }; | ||
| 283 | } | ||
| 284 | |||
| 285 | export function getMobileThemeVariables( | ||
| 286 | themeId: MobileThemeId, | ||
| 287 | appearance: MobileThemeAppearance, | ||
| 288 | overrides: Partial<MobileThemeVariables> | null = null, | ||
| 289 | ): MobileThemeVariables { | ||
| 290 | const baseVariables = (() => { | ||
| 291 | if (themeId === DEFAULT_MOBILE_THEME_ID) return DEFAULT_MOBILE_THEME_VARIABLES[appearance]; | ||
| 292 | const theme = | ||
| 293 | BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; | ||
| 294 | const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; | ||
| 295 | return createMobileThemeVariables(colors, appearance); | ||
| 296 | })(); | ||
| 297 | |||
| 298 | // The complete base record guarantees that optional overrides cannot leave a token undefined. | ||
| 299 | return overrides ? ({ ...baseVariables, ...overrides } as MobileThemeVariables) : baseVariables; | ||
| 300 | } | ||
| 301 | |||
| 302 | export function getMobileThemePreviewColors( | ||
| 303 | themeId: MobileThemeId, | ||
| 304 | appearance: MobileThemeAppearance, | ||
| 305 | ): ThemePreviewColors { | ||
| 306 | if (themeId === DEFAULT_MOBILE_THEME_ID) return STANDARD_THEME_PREVIEW_COLORS[appearance]; | ||
| 307 | const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; | ||
| 308 | const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; | ||
| 309 | return { | ||
| 310 | canvas: themeColorToNativeColor(colors.canvas), | ||
| 311 | accent: themeColorToNativeColor(colors.accent), | ||
| 312 | messageAction: themeColorToNativeColor(colors.messageAction), | ||
| 313 | }; | ||
| 314 | } | ||
apps/mobile/src/lib/storage.test.ts+19| ... | @@ -177,6 +177,25 @@ describe("mobile connection storage", () => { | ... | @@ -177,6 +177,25 @@ describe("mobile connection storage", () => { |
| 177 | await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); | 177 | await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); |
| 178 | }); | 178 | }); |
| 179 | 179 | ||
| 180 | it("persists independent light and dark theme choices", async () => { | ||
| 181 | mocks.setPreferencesJson( | ||
| 182 | JSON.stringify({ | ||
| 183 | themeId: "grove", | ||
| 184 | lightThemeId: "iris", | ||
| 185 | darkThemeId: "ocean", | ||
| 186 | themeMode: "system", | ||
| 187 | }), | ||
| 188 | 10, | ||
| 189 | ); | ||
| 190 | |||
| 191 | await expect(loadPreferences()).resolves.toEqual({ | ||
| 192 | themeId: "grove", | ||
| 193 | lightThemeId: "iris", | ||
| 194 | darkThemeId: "ocean", | ||
| 195 | themeMode: "system", | ||
| 196 | }); | ||
| 197 | }); | ||
| 198 | |||
| 180 | it("falls back to secure storage when SQLite cannot save preferences", async () => { | 199 | it("falls back to secure storage when SQLite cannot save preferences", async () => { |
| 181 | mocks.setDatabaseFailures(true, true); | 200 | mocks.setDatabaseFailures(true, true); |
| 182 | await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); | 201 | await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); |
apps/mobile/src/lib/useMobileNavigationTheme.ts created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native"; | ||
| 2 | import { useMemo } from "react"; | ||
| 3 | |||
| 4 | import type { MobileThemeAppearance } from "./mobileTheme"; | ||
| 5 | import { useThemeColor } from "./useThemeColor"; | ||
| 6 | |||
| 7 | export function useMobileNavigationTheme(appearance: MobileThemeAppearance): Theme { | ||
| 8 | const primary = String(useThemeColor("--color-primary")); | ||
| 9 | const background = String(useThemeColor("--color-screen")); | ||
| 10 | const card = String(useThemeColor("--color-sheet-solid")); | ||
| 11 | const text = String(useThemeColor("--color-foreground")); | ||
| 12 | const border = String(useThemeColor("--color-header-border")); | ||
| 13 | const notification = String(useThemeColor("--color-danger-foreground")); | ||
| 14 | |||
| 15 | return useMemo(() => { | ||
| 16 | const base = appearance === "dark" ? DarkTheme : DefaultTheme; | ||
| 17 | return { | ||
| 18 | ...base, | ||
| 19 | colors: { ...base.colors, primary, background, card, text, border, notification }, | ||
| 20 | }; | ||
| 21 | }, [appearance, background, border, card, notification, primary, text]); | ||
| 22 | } | ||
apps/mobile/src/native/sheet-surface.ts+2-23| ... | @@ -1,28 +1,7 @@ | ... | @@ -1,28 +1,7 @@ |
| 1 | import { DynamicColorIOS, Platform, type ColorValue, type ViewStyle } from "react-native"; | ||
| 2 | |||
| 3 | /** | 1 | /** |
| 4 | * One opaque surface for content rendered inside a native form sheet. | 2 | * Form sheets inherit the live React Navigation palette supplied by App. Each |
| 5 | * | 3 | * presented route paints its content with bg-sheet, including nested pushes. |
| 6 | * UIKit owns the outer sheet material and rounded corners. The presented route | ||
| 7 | * owns this surface so nested navigators never expose a differently colored | ||
| 8 | * native container while their screens move. | ||
| 9 | */ | ||
| 10 | export const NATIVE_SHEET_SURFACE_COLOR: ColorValue | undefined = | ||
| 11 | Platform.OS === "ios" ? DynamicColorIOS({ light: "#f2f2f7", dark: "#0e0e0e" }) : undefined; | ||
| 12 | |||
| 13 | export const NATIVE_SHEET_SURFACE_CONTENT_STYLE: ViewStyle | undefined = | ||
| 14 | NATIVE_SHEET_SURFACE_COLOR === undefined | ||
| 15 | ? undefined | ||
| 16 | : { backgroundColor: NATIVE_SHEET_SURFACE_COLOR }; | ||
| 17 | |||
| 18 | /** | ||
| 19 | * Paint the adaptive background on the presented screen itself. Nested stacks | ||
| 20 | * can stay transparent over this single surface, so a push never exposes an | ||
| 21 | * unpainted form-sheet host behind the moving child view controllers. | ||
| 22 | */ | 4 | */ |
| 23 | export const FORM_SHEET_PRESENTATION_OPTIONS = { | 5 | export const FORM_SHEET_PRESENTATION_OPTIONS = { |
| 24 | presentation: "formSheet" as const, | 6 | presentation: "formSheet" as const, |
| 25 | ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined | ||
| 26 | ? null | ||
| 27 | : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }), | ||
| 28 | }; | 7 | }; |
apps/mobile/src/persistence/mobile-preferences.ts+34| ... | @@ -6,6 +6,7 @@ import * as Ref from "effect/Ref"; | ... | @@ -6,6 +6,7 @@ import * as Ref from "effect/Ref"; |
| 6 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as Semaphore from "effect/Semaphore"; | 7 | import * as Semaphore from "effect/Semaphore"; |
| 8 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; | 8 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 9 | import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; | ||
| 9 | 10 | ||
| 10 | import * as MobileDatabase from "./mobile-database"; | 11 | import * as MobileDatabase from "./mobile-database"; |
| 11 | import * as MobileSecureStorage from "./mobile-secure-storage"; | 12 | import * as MobileSecureStorage from "./mobile-secure-storage"; |
| ... | @@ -16,6 +17,10 @@ const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; | ... | @@ -16,6 +17,10 @@ const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; |
| 16 | 17 | ||
| 17 | export interface Preferences { | 18 | export interface Preferences { |
| 18 | readonly liveActivitiesEnabled?: boolean; | 19 | readonly liveActivitiesEnabled?: boolean; |
| 20 | readonly themeId?: MobileThemeId; | ||
| 21 | readonly lightThemeId?: MobileThemeId; | ||
| 22 | readonly darkThemeId?: MobileThemeId; | ||
| 23 | readonly themeMode?: MobileThemeMode; | ||
| 19 | readonly baseFontSize?: number; | 24 | readonly baseFontSize?: number; |
| 20 | readonly terminalFontSize?: number | null; | 25 | readonly terminalFontSize?: number | null; |
| 21 | readonly markdownFontSize?: number; | 26 | readonly markdownFontSize?: number; |
| ... | @@ -79,6 +84,10 @@ export class MobilePreferencesStore extends Context.Service< | ... | @@ -79,6 +84,10 @@ export class MobilePreferencesStore extends Context.Service< |
| 79 | function sanitizePreferences(parsed: Preferences): Preferences { | 84 | function sanitizePreferences(parsed: Preferences): Preferences { |
| 80 | const preferences: { | 85 | const preferences: { |
| 81 | liveActivitiesEnabled?: boolean; | 86 | liveActivitiesEnabled?: boolean; |
| 87 | themeId?: MobileThemeId; | ||
| 88 | lightThemeId?: MobileThemeId; | ||
| 89 | darkThemeId?: MobileThemeId; | ||
| 90 | themeMode?: MobileThemeMode; | ||
| 82 | baseFontSize?: number; | 91 | baseFontSize?: number; |
| 83 | terminalFontSize?: number | null; | 92 | terminalFontSize?: number | null; |
| 84 | markdownFontSize?: number; | 93 | markdownFontSize?: number; |
| ... | @@ -96,6 +105,31 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -96,6 +105,31 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 96 | if (typeof parsed.liveActivitiesEnabled === "boolean") { | 105 | if (typeof parsed.liveActivitiesEnabled === "boolean") { |
| 97 | preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; | 106 | preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; |
| 98 | } | 107 | } |
| 108 | if ( | ||
| 109 | typeof parsed.themeId === "string" && | ||
| 110 | (MOBILE_THEME_IDS as readonly string[]).includes(parsed.themeId) | ||
| 111 | ) { | ||
| 112 | preferences.themeId = parsed.themeId as MobileThemeId; | ||
| 113 | } | ||
| 114 | if ( | ||
| 115 | typeof parsed.lightThemeId === "string" && | ||
| 116 | (MOBILE_THEME_IDS as readonly string[]).includes(parsed.lightThemeId) | ||
| 117 | ) { | ||
| 118 | preferences.lightThemeId = parsed.lightThemeId as MobileThemeId; | ||
| 119 | } | ||
| 120 | if ( | ||
| 121 | typeof parsed.darkThemeId === "string" && | ||
| 122 | (MOBILE_THEME_IDS as readonly string[]).includes(parsed.darkThemeId) | ||
| 123 | ) { | ||
| 124 | preferences.darkThemeId = parsed.darkThemeId as MobileThemeId; | ||
| 125 | } | ||
| 126 | if ( | ||
| 127 | parsed.themeMode === "system" || | ||
| 128 | parsed.themeMode === "light" || | ||
| 129 | parsed.themeMode === "dark" | ||
| 130 | ) { | ||
| 131 | preferences.themeMode = parsed.themeMode; | ||
| 132 | } | ||
| 99 | if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; | 133 | if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; |
| 100 | if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { | 134 | if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { |
| 101 | preferences.terminalFontSize = parsed.terminalFontSize; | 135 | preferences.terminalFontSize = parsed.terminalFontSize; |
apps/web/src/components/settings/ThemePreviewCircles.tsx+18-17| ... | @@ -1,5 +1,9 @@ | ... | @@ -1,5 +1,9 @@ |
| 1 | import { MoonIcon, SunIcon } from "lucide-react"; | 1 | import { MoonIcon, SunIcon } from "lucide-react"; |
| 2 | import type { CSSProperties } from "react"; | 2 | import type { CSSProperties } from "react"; |
| 3 | import { | ||
| 4 | STANDARD_THEME_PREVIEW_COLORS as SHARED_STANDARD_THEME_PREVIEW_COLORS, | ||
| 5 | THEME_PREVIEW_RENDER_SPECS, | ||
| 6 | } from "@t3tools/shared/themePreview"; | ||
| 3 | import { cn } from "../../lib/utils"; | 7 | import { cn } from "../../lib/utils"; |
| 4 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 8 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 5 | import { | 9 | import { |
| ... | @@ -37,21 +41,17 @@ const STANDARD_THEME_PREVIEW_COLORS: Record< | ... | @@ -37,21 +41,17 @@ const STANDARD_THEME_PREVIEW_COLORS: Record< |
| 37 | > = { | 41 | > = { |
| 38 | light: { | 42 | light: { |
| 39 | sidebar: "#fafafa", | 43 | sidebar: "#fafafa", |
| 40 | canvas: "#fcfcfc", | ||
| 41 | surface: "#ffffff", | 44 | surface: "#ffffff", |
| 42 | accentSurface: "#f4f4f5", | 45 | accentSurface: "#f4f4f5", |
| 43 | accent: "#f4f4f5", | ||
| 44 | messageSurface: "#e4e4e7", | 46 | messageSurface: "#e4e4e7", |
| 45 | messageAction: "#4f46e5", | 47 | ...SHARED_STANDARD_THEME_PREVIEW_COLORS.light, |
| 46 | }, | 48 | }, |
| 47 | dark: { | 49 | dark: { |
| 48 | sidebar: "#0f0f10", | 50 | sidebar: "#0f0f10", |
| 49 | canvas: "#0a0a0a", | ||
| 50 | surface: "#121212", | 51 | surface: "#121212", |
| 51 | accentSurface: "#27272a", | 52 | accentSurface: "#27272a", |
| 52 | accent: "#1c1c1f", | ||
| 53 | messageSurface: "#27272a", | 53 | messageSurface: "#27272a", |
| 54 | messageAction: "#8b9cff", | 54 | ...SHARED_STANDARD_THEME_PREVIEW_COLORS.dark, |
| 55 | }, | 55 | }, |
| 56 | }; | 56 | }; |
| 57 | 57 | ||
| ... | @@ -102,23 +102,20 @@ function getThemePreviewStyle( | ... | @@ -102,23 +102,20 @@ function getThemePreviewStyle( |
| 102 | colors: ThemeCardPreviewColors, | 102 | colors: ThemeCardPreviewColors, |
| 103 | mode: ThemeAppearance, | 103 | mode: ThemeAppearance, |
| 104 | ): CSSProperties { | 104 | ): CSSProperties { |
| 105 | const isDark = mode === "dark"; | 105 | const spec = THEME_PREVIEW_RENDER_SPECS[mode]; |
| 106 | // The canvas carries the ball's light/dark identity, so it stays dominant: | 106 | // The canvas carries the ball's light/dark identity, so it stays dominant: |
| 107 | // a near-true base with a contained accent glow, instead of an accent wash | 107 | // a near-true base with a contained accent glow, instead of an accent wash |
| 108 | // that makes both modes read alike. | 108 | // that makes both modes read alike. |
| 109 | const modeBase = isDark | 109 | const modeBase = `color-mix(in oklab, ${colors.canvas} ${spec.baseWeight * 100}%, ${spec.baseTarget})`; |
| 110 | ? `color-mix(in oklab, ${colors.canvas} 80%, #09090b)` | 110 | const accentPosition = `${spec.accent.center[0] * 100}% ${spec.accent.center[1] * 100}%`; |
| 111 | : `color-mix(in oklab, ${colors.canvas} 80%, #ffffff)`; | 111 | const actionPosition = `${spec.action.center[0] * 100}% ${spec.action.center[1] * 100}%`; |
| 112 | const accentPosition = isDark ? "28% 78%" : "72% 22%"; | ||
| 113 | const actionPosition = isDark ? "82% 18%" : "18% 82%"; | ||
| 114 | const accentFade = isDark ? 62 : 72; | ||
| 115 | return { | 112 | return { |
| 116 | backgroundColor: modeBase, | 113 | backgroundColor: modeBase, |
| 117 | backgroundImage: [ | 114 | backgroundImage: [ |
| 118 | `radial-gradient(circle at ${accentPosition} in oklab, ${colors.accent} 0%, color-mix(in oklab, ${colors.accent} ${accentFade}%, transparent) 28%, transparent 58%)`, | 115 | `radial-gradient(circle at ${accentPosition} in oklab, ${colors.accent} 0%, color-mix(in oklab, ${colors.accent} ${spec.accent.middleOpacity * 100}%, transparent) ${spec.accent.middleOffset * 100}%, transparent ${spec.accent.endOffset * 100}%)`, |
| 119 | // The action color is a soft tint from the opposite corner, not a second | 116 | // The action color is a soft tint from the opposite corner, not a second |
| 120 | // light source — two bright hotspots read as headlights. | 117 | // light source — two bright hotspots read as headlights. |
| 121 | `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} 45%, transparent) 0%, transparent 55%)`, | 118 | `radial-gradient(circle at ${actionPosition} in oklab, color-mix(in oklab, ${colors.messageAction} ${spec.action.startOpacity * 100}%, transparent) 0%, transparent ${spec.action.endOffset * 100}%)`, |
| 122 | ].join(", "), | 119 | ].join(", "), |
| 123 | }; | 120 | }; |
| 124 | } | 121 | } |
| ... | @@ -145,8 +142,12 @@ export function ThemePreviewCircle({ | ... | @@ -145,8 +142,12 @@ export function ThemePreviewCircle({ |
| 145 | style={{ boxShadow: themePreviewEdgeShadow(mode) }} | 142 | style={{ boxShadow: themePreviewEdgeShadow(mode) }} |
| 146 | > | 143 | > |
| 147 | <span | 144 | <span |
| 148 | className="absolute inset-0 scale-110 rounded-full blur-[3px]" | 145 | className="absolute inset-0 rounded-full" |
| 149 | style={getThemePreviewStyle(colors, mode)} | 146 | style={{ |
| 147 | ...getThemePreviewStyle(colors, mode), | ||
| 148 | filter: `blur(${THEME_PREVIEW_RENDER_SPECS[mode].blurAt56Px}px)`, | ||
| 149 | transform: `scale(${THEME_PREVIEW_RENDER_SPECS[mode].scale})`, | ||
| 150 | }} | ||
| 150 | /> | 151 | /> |
| 151 | </span> | 152 | </span> |
| 152 | ); | 153 | ); |
apps/web/src/themePalette.test.ts+11| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; | ||
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 4 | applyThemeColorPreview, | 5 | applyThemeColorPreview, |
| ... | @@ -78,6 +79,16 @@ function contrastRatio(first: string, second: string): number { | ... | @@ -78,6 +79,16 @@ function contrastRatio(first: string, second: string): number { |
| 78 | } | 79 | } |
| 79 | 80 | ||
| 80 | describe("theme files", () => { | 81 | describe("theme files", () => { |
| 82 | it("keeps every built-in palette value in canonical OKLCH form", () => { | ||
| 83 | for (const theme of BUILT_IN_THEMES) { | ||
| 84 | for (const colors of [theme.colors, ...Object.values(theme.variants ?? {})]) { | ||
| 85 | for (const value of Object.values(colors)) { | ||
| 86 | expect(toCanonicalThemeColor(value)).toBe(value); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | }); | ||
| 91 | |||
| 81 | it("derives a readable palette from extreme simple-editor colors", () => { | 92 | it("derives a readable palette from extreme simple-editor colors", () => { |
| 82 | const light = createManagedThemeColors("light", "#111827", "#ffff00"); | 93 | const light = createManagedThemeColors("light", "#111827", "#ffff00"); |
| 83 | const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00"); | 94 | const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00"); |
apps/web/src/themePalette.ts+18-335| ... | @@ -1,6 +1,23 @@ | ... | @@ -1,6 +1,23 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import "culori/css"; | 2 | import "culori/css"; |
| 3 | import { converter, parse } from "culori/fn"; | 3 | import { converter, parse } from "culori/fn"; |
| 4 | import { | ||
| 5 | BUILT_IN_THEMES, | ||
| 6 | EMBER_THEME, | ||
| 7 | GROVE_THEME, | ||
| 8 | IRIS_THEME, | ||
| 9 | OCEAN_THEME, | ||
| 10 | T3_CHAT_THEME, | ||
| 11 | THEME_COLOR_ROLES, | ||
| 12 | type ThemeAppearance, | ||
| 13 | type ThemeColorRole, | ||
| 14 | type ThemeColors, | ||
| 15 | type ThemeDefinition, | ||
| 16 | type ThemeVariants, | ||
| 17 | } from "@t3tools/shared/themePalettes"; | ||
| 18 | |||
| 19 | export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES }; | ||
| 20 | export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; | ||
| 4 | 21 | ||
| 5 | export const T3_CHAT_THEME_ID = "t3-chat" as const; | 22 | export const T3_CHAT_THEME_ID = "t3-chat" as const; |
| 6 | export const T3_CHAT_THEME_LABEL = "T3 Chat"; | 23 | export const T3_CHAT_THEME_LABEL = "T3 Chat"; |
| ... | @@ -23,90 +40,11 @@ const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark"; | ... | @@ -23,90 +40,11 @@ const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark"; |
| 23 | export const ThemePreference = Schema.String; | 40 | export const ThemePreference = Schema.String; |
| 24 | export type ThemePreference = typeof ThemePreference.Type; | 41 | export type ThemePreference = typeof ThemePreference.Type; |
| 25 | 42 | ||
| 26 | export const THEME_COLOR_ROLES = [ | ||
| 27 | "canvas", | ||
| 28 | "chrome", | ||
| 29 | "toolbar", | ||
| 30 | "toolbarForeground", | ||
| 31 | "toolbarBorder", | ||
| 32 | "toolbarControl", | ||
| 33 | "toolbarControlForeground", | ||
| 34 | "toolbarControlHover", | ||
| 35 | "surface", | ||
| 36 | "surfaceRaised", | ||
| 37 | "surfaceOverlay", | ||
| 38 | "text", | ||
| 39 | "textMuted", | ||
| 40 | "border", | ||
| 41 | "input", | ||
| 42 | "focus", | ||
| 43 | "accent", | ||
| 44 | "accentForeground", | ||
| 45 | "secondary", | ||
| 46 | "secondaryForeground", | ||
| 47 | "muted", | ||
| 48 | "mutedForeground", | ||
| 49 | "placeholder", | ||
| 50 | "secondaryLabel", | ||
| 51 | "iconMuted", | ||
| 52 | "error", | ||
| 53 | "errorForeground", | ||
| 54 | "errorSurface", | ||
| 55 | "warning", | ||
| 56 | "warningForeground", | ||
| 57 | "warningSurface", | ||
| 58 | "update", | ||
| 59 | "updateForeground", | ||
| 60 | "updateSurface", | ||
| 61 | "accentSurface", | ||
| 62 | "accentSurfaceForeground", | ||
| 63 | "messageSurface", | ||
| 64 | "messageForeground", | ||
| 65 | "messageAction", | ||
| 66 | "messageActionForeground", | ||
| 67 | "messageActionHover", | ||
| 68 | "codeBackground", | ||
| 69 | "codeForeground", | ||
| 70 | "sidebar", | ||
| 71 | "sidebarForeground", | ||
| 72 | "sidebarMutedForeground", | ||
| 73 | "sidebarControlSurface", | ||
| 74 | "sidebarRowHover", | ||
| 75 | "sidebarRowActive", | ||
| 76 | "sidebarRowSelected", | ||
| 77 | "sidebarBorder", | ||
| 78 | "terminalBackground", | ||
| 79 | "terminalForeground", | ||
| 80 | "terminalCursor", | ||
| 81 | "terminalSelection", | ||
| 82 | "terminalScrollbar", | ||
| 83 | "terminalScrollbarHover", | ||
| 84 | ] as const; | ||
| 85 | |||
| 86 | export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number]; | ||
| 87 | const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES); | 43 | const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES); |
| 88 | export type ThemeAppearance = "light" | "dark"; | ||
| 89 | |||
| 90 | export type ThemeColors = Readonly<Record<ThemeColorRole, string>>; | ||
| 91 | export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>; | 44 | export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>; |
| 92 | export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>; | ||
| 93 | export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>; | 45 | export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>; |
| 94 | export type ThemePreferenceMode = ThemeAppearance | "system"; | 46 | export type ThemePreferenceMode = ThemeAppearance | "system"; |
| 95 | export type ThemeCollection = Readonly<{ id: string; label: string }>; | 47 | export type ThemeCollection = Readonly<{ id: string; label: string }>; |
| 96 | export type ThemeDefinition = Readonly<{ | ||
| 97 | id: string; | ||
| 98 | label: string; | ||
| 99 | appearance: ThemeAppearance; | ||
| 100 | colors: ThemeColors; | ||
| 101 | variants?: ThemeVariants; | ||
| 102 | /** Groups related imported variants into one library card. */ | ||
| 103 | collection?: ThemeCollection; | ||
| 104 | /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */ | ||
| 105 | sidebarArtwork?: boolean; | ||
| 106 | /** True when the palette was generated by the guided editor from its | ||
| 107 | * canvas and accent; such themes reopen in guided mode. */ | ||
| 108 | managed?: boolean; | ||
| 109 | }>; | ||
| 110 | export type ThemeFile = Readonly<{ | 48 | export type ThemeFile = Readonly<{ |
| 111 | version: typeof THEME_FILE_VERSION; | 49 | version: typeof THEME_FILE_VERSION; |
| 112 | id: string; | 50 | id: string; |
| ... | @@ -366,155 +304,6 @@ function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null { | ... | @@ -366,155 +304,6 @@ function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null { |
| 366 | return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null; | 304 | return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null; |
| 367 | } | 305 | } |
| 368 | 306 | ||
| 369 | /** | ||
| 370 | * Maintainer palettes use product color roles rather than Tailwind or component | ||
| 371 | * names so the same definitions can feed other clients and native surfaces. | ||
| 372 | */ | ||
| 373 | // Measured from the live t3.chat default theme. Translucent chat surfaces are | ||
| 374 | // flattened over --chat-background so this opaque palette reproduces the | ||
| 375 | // pixels users see after T3 Chat's blur and noise layers are composited. | ||
| 376 | // Foreground pairs deviate where necessary to keep normal text at WCAG AA. | ||
| 377 | const T3_CHAT_LIGHT_COLORS: ThemeColors = { | ||
| 378 | canvas: "#fdf7fd", | ||
| 379 | // T3 Code's workspace header belongs to the chat panel, so keep it seamless | ||
| 380 | // with the light chat canvas rather than mapping it to T3 Chat's outer shell. | ||
| 381 | chrome: "#fdf7fd", | ||
| 382 | toolbar: "#fdf7fd", | ||
| 383 | toolbarForeground: "#501854", | ||
| 384 | toolbarBorder: "#efbdeb", | ||
| 385 | // T3 Chat's light chrome controls sit on its pale gradient-noise surface, | ||
| 386 | // not the substantially darker solid accent token. | ||
| 387 | toolbarControl: "#f3e6f5", | ||
| 388 | toolbarControlForeground: "#501854", | ||
| 389 | toolbarControlHover: "#eccfe3", | ||
| 390 | surface: "#faf3fb", | ||
| 391 | surfaceRaised: "#fdfafd", | ||
| 392 | surfaceOverlay: "#ffffff", | ||
| 393 | text: "#501854", | ||
| 394 | textMuted: "#ac1668", | ||
| 395 | border: "#eee1ed", | ||
| 396 | input: "#e7c1dc", | ||
| 397 | focus: "#db2777", | ||
| 398 | accent: "#db2777", | ||
| 399 | accentForeground: "#ffffff", | ||
| 400 | secondary: "#f1c4e6", | ||
| 401 | secondaryForeground: "#77347c", | ||
| 402 | muted: "#eaa7cb", | ||
| 403 | mutedForeground: "#8d1255", | ||
| 404 | placeholder: "#8b5f90", | ||
| 405 | secondaryLabel: "#ac1668", | ||
| 406 | iconMuted: "#ac1668", | ||
| 407 | error: "#f7086c", | ||
| 408 | errorForeground: "#9d174d", | ||
| 409 | errorSurface: "#fde4f1", | ||
| 410 | warning: "#f59e0b", | ||
| 411 | warningForeground: "#b05109", | ||
| 412 | warningSurface: "#fcf0ea", | ||
| 413 | update: "#db2777", | ||
| 414 | updateForeground: "#ac1668", | ||
| 415 | updateSurface: "#fadfef", | ||
| 416 | accentSurface: "#f3e6f5", | ||
| 417 | accentSurfaceForeground: "#454554", | ||
| 418 | messageSurface: "#f7def2", | ||
| 419 | messageForeground: "#492c61", | ||
| 420 | messageAction: "#db2777", | ||
| 421 | messageActionForeground: "#ffffff", | ||
| 422 | messageActionHover: "#c12269", | ||
| 423 | // T3 Chat uses a light lavender code surface in light mode. Keeping the | ||
| 424 | // dark plum pair here also leaked the dark palette into T3 Code's diffs. | ||
| 425 | codeBackground: "#f5ecf9", | ||
| 426 | codeForeground: "#673c8b", | ||
| 427 | // The live sidebar is transparent over T3 Chat's outer shell. Use that | ||
| 428 | // rendered shell color rather than its unused, darker sidebar token. | ||
| 429 | sidebar: "#f2e1f4", | ||
| 430 | sidebarForeground: "#454554", | ||
| 431 | sidebarMutedForeground: "#ac1668", | ||
| 432 | sidebarControlSurface: "#f8f8f7", | ||
| 433 | sidebarRowHover: "#f8f8f7", | ||
| 434 | sidebarRowActive: "#f8f8f7", | ||
| 435 | sidebarRowSelected: "#f8f8f7", | ||
| 436 | sidebarBorder: "#eceae9", | ||
| 437 | terminalBackground: "#fdf7fd", | ||
| 438 | terminalForeground: "#501854", | ||
| 439 | terminalCursor: "#db2777", | ||
| 440 | terminalSelection: "#f1c4e6", | ||
| 441 | terminalScrollbar: "#e7c1dc", | ||
| 442 | terminalScrollbarHover: "#eaa7cb", | ||
| 443 | }; | ||
| 444 | |||
| 445 | const T3_CHAT_DARK_COLORS: ThemeColors = { | ||
| 446 | canvas: "#1f1a24", | ||
| 447 | // T3 Code's workspace header belongs to the chat panel, so keep it seamless | ||
| 448 | // with the canvas rather than mapping it to T3 Chat's outer shell. | ||
| 449 | chrome: "#1f1a24", | ||
| 450 | toolbar: "#1f1a24", | ||
| 451 | toolbarForeground: "#f9f8fb", | ||
| 452 | toolbarBorder: "#27242c", | ||
| 453 | toolbarControl: "#362d3d", | ||
| 454 | toolbarControlForeground: "#d4c7e1", | ||
| 455 | toolbarControlHover: "#463753", | ||
| 456 | // Cards and panels stay in T3 Chat's plum surface family. Near-black here | ||
| 457 | // made the right-panel surface picker look unrelated to the chat canvas. | ||
| 458 | surface: "#29232d", | ||
| 459 | // Pre-composited for the composer's 80% glass layer; this resolves to the | ||
| 460 | // measured #29232d input fill over the canvas. | ||
| 461 | surfaceRaised: "#2c2631", | ||
| 462 | surfaceOverlay: "#100a0e", | ||
| 463 | text: "#f9f8fb", | ||
| 464 | textMuted: "#e7d0dd", | ||
| 465 | border: "#27242c", | ||
| 466 | input: "#302029", | ||
| 467 | focus: "#db2777", | ||
| 468 | accent: "#a3004c", | ||
| 469 | accentForeground: "#fbd0e8", | ||
| 470 | secondary: "#362d3d", | ||
| 471 | secondaryForeground: "#d4c7e1", | ||
| 472 | muted: "#423a45", | ||
| 473 | mutedForeground: "#e7d0dd", | ||
| 474 | placeholder: "#968d9f", | ||
| 475 | secondaryLabel: "#e7d0dd", | ||
| 476 | iconMuted: "#d4c7e1", | ||
| 477 | error: "#9d174d", | ||
| 478 | errorForeground: "#fbd0e8", | ||
| 479 | errorSurface: "#331a2b", | ||
| 480 | warning: "#f59e0b", | ||
| 481 | warningForeground: "#fbbf24", | ||
| 482 | warningSurface: "#412f20", | ||
| 483 | update: "#a3004c", | ||
| 484 | updateForeground: "#fbd0e8", | ||
| 485 | updateSurface: "#37152b", | ||
| 486 | accentSurface: "#463753", | ||
| 487 | accentSurfaceForeground: "#f8f1f5", | ||
| 488 | messageSurface: "#2b2431", | ||
| 489 | messageForeground: "#f2ebfa", | ||
| 490 | messageAction: "#a3004c", | ||
| 491 | messageActionForeground: "#fbd0e8", | ||
| 492 | messageActionHover: "#a2004c", | ||
| 493 | // Diffs and file previews are full workspace surfaces in T3 Code. Keep them | ||
| 494 | // continuous with the themed canvas instead of dropping to near-black. | ||
| 495 | codeBackground: "#1f1a24", | ||
| 496 | codeForeground: "#d8c3ef", | ||
| 497 | // The live sidebar starts from #131314, then gains its hue from a pink | ||
| 498 | // gradient/noise stack. This pre-grain base lands on the same #1a131a | ||
| 499 | // visible shell color after our surface-grain layer is composited. | ||
| 500 | sidebar: "#171018", | ||
| 501 | sidebarForeground: "#f4f4f5", | ||
| 502 | sidebarMutedForeground: "#e7d0dd", | ||
| 503 | sidebarControlSurface: "#261922", | ||
| 504 | sidebarRowHover: "#261922", | ||
| 505 | sidebarRowActive: "#261922", | ||
| 506 | sidebarRowSelected: "#261922", | ||
| 507 | // T3 Chat draws the chat panel edge in this muted pink. The resize rail uses | ||
| 508 | // the same role on hover, so it stays pink instead of falling back to black. | ||
| 509 | sidebarBorder: "#322028", | ||
| 510 | terminalBackground: "#1f1a24", | ||
| 511 | terminalForeground: "#f9f8fb", | ||
| 512 | terminalCursor: "#db2777", | ||
| 513 | terminalSelection: "#362d3d", | ||
| 514 | terminalScrollbar: "#302029", | ||
| 515 | terminalScrollbarHover: "#423a45", | ||
| 516 | }; | ||
| 517 | |||
| 518 | /** | 307 | /** |
| 519 | * The palette T3 Code wears with no theme installed, captured from the app's | 308 | * The palette T3 Code wears with no theme installed, captured from the app's |
| 520 | * stock tokens (index.css) so a draft seeded from the default look paints the | 309 | * stock tokens (index.css) so a draft seeded from the default look paints the |
| ... | @@ -1431,43 +1220,11 @@ export function createManagedThemeColors( | ... | @@ -1431,43 +1220,11 @@ export function createManagedThemeColors( |
| 1431 | }; | 1220 | }; |
| 1432 | } | 1221 | } |
| 1433 | 1222 | ||
| 1434 | export const T3_CHAT_THEME: ThemeDefinition = { | ||
| 1435 | id: T3_CHAT_THEME_ID, | ||
| 1436 | label: T3_CHAT_THEME_LABEL, | ||
| 1437 | appearance: "light", | ||
| 1438 | colors: decodeThemeColors(T3_CHAT_LIGHT_COLORS), | ||
| 1439 | variants: { | ||
| 1440 | dark: decodeThemeColors(T3_CHAT_DARK_COLORS), | ||
| 1441 | }, | ||
| 1442 | sidebarArtwork: true, | ||
| 1443 | }; | ||
| 1444 | |||
| 1445 | /** Theme-file defaults follow the flagship palette for the requested mode. */ | 1223 | /** Theme-file defaults follow the flagship palette for the requested mode. */ |
| 1446 | export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { | 1224 | export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { |
| 1447 | return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; | 1225 | return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; |
| 1448 | } | 1226 | } |
| 1449 | 1227 | ||
| 1450 | /** | ||
| 1451 | * A companion action color in the T3 Chat mold. This gives send buttons, | ||
| 1452 | * status pills, and theme previews a second voice; foreground and hover follow | ||
| 1453 | * the same rules as the managed generator. | ||
| 1454 | */ | ||
| 1455 | function themeActionColors( | ||
| 1456 | action: string, | ||
| 1457 | ): Pick<ThemeColors, "messageAction" | "messageActionForeground" | "messageActionHover"> { | ||
| 1458 | const rgb = parseThemeRgbColor(action, THEME_DARK_FOREGROUND); | ||
| 1459 | const foreground = readableThemeForeground(rgb); | ||
| 1460 | const towardOpposite = | ||
| 1461 | foreground === THEME_LIGHT_FOREGROUND || foreground === THEME_WHITE_FOREGROUND | ||
| 1462 | ? THEME_BLACK_FOREGROUND | ||
| 1463 | : THEME_WHITE_FOREGROUND; | ||
| 1464 | return { | ||
| 1465 | messageAction: toCanonicalThemeColor(action) ?? themeRgbToThemeColor(rgb), | ||
| 1466 | messageActionForeground: themeRgbToThemeColor(foreground), | ||
| 1467 | messageActionHover: themeRgbToThemeColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)), | ||
| 1468 | }; | ||
| 1469 | } | ||
| 1470 | |||
| 1471 | /** | 1228 | /** |
| 1472 | * Update one Advanced-editor color family without normalizing the rest of an | 1229 | * Update one Advanced-editor color family without normalizing the rest of an |
| 1473 | * imported or hand-tuned palette. The editor exposes a representative role | 1230 | * imported or hand-tuned palette. The editor exposes a representative role |
| ... | @@ -1660,81 +1417,7 @@ export function updateThemeColorFamily( | ... | @@ -1660,81 +1417,7 @@ export function updateThemeColorFamily( |
| 1660 | } | 1417 | } |
| 1661 | } | 1418 | } |
| 1662 | 1419 | ||
| 1663 | export const GROVE_THEME: ThemeDefinition = { | 1420 | const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = BUILT_IN_THEMES; |
| 1664 | id: GROVE_THEME_ID, | ||
| 1665 | label: GROVE_THEME_LABEL, | ||
| 1666 | appearance: "light", | ||
| 1667 | colors: { | ||
| 1668 | ...createManagedThemeColors("light", "#f2f8f4", "#19734a"), | ||
| 1669 | ...themeActionColors("#8f6410"), | ||
| 1670 | }, | ||
| 1671 | variants: { | ||
| 1672 | dark: { | ||
| 1673 | ...createManagedThemeColors("dark", "#1d2b24", "#69d69a"), | ||
| 1674 | ...themeActionColors("#e3b34e"), | ||
| 1675 | }, | ||
| 1676 | }, | ||
| 1677 | sidebarArtwork: true, | ||
| 1678 | }; | ||
| 1679 | |||
| 1680 | export const OCEAN_THEME: ThemeDefinition = { | ||
| 1681 | id: OCEAN_THEME_ID, | ||
| 1682 | label: OCEAN_THEME_LABEL, | ||
| 1683 | appearance: "light", | ||
| 1684 | colors: { | ||
| 1685 | ...createManagedThemeColors("light", "#f2f7fb", "#2878b8"), | ||
| 1686 | ...themeActionColors("#0a6f75"), | ||
| 1687 | }, | ||
| 1688 | variants: { | ||
| 1689 | dark: { | ||
| 1690 | ...createManagedThemeColors("dark", "#1b2938", "#70b9ee"), | ||
| 1691 | ...themeActionColors("#5bd0d6"), | ||
| 1692 | }, | ||
| 1693 | }, | ||
| 1694 | sidebarArtwork: true, | ||
| 1695 | }; | ||
| 1696 | |||
| 1697 | export const EMBER_THEME: ThemeDefinition = { | ||
| 1698 | id: EMBER_THEME_ID, | ||
| 1699 | label: EMBER_THEME_LABEL, | ||
| 1700 | appearance: "light", | ||
| 1701 | colors: { | ||
| 1702 | ...createManagedThemeColors("light", "#fff6ef", "#c4602f"), | ||
| 1703 | ...themeActionColors("#b23535"), | ||
| 1704 | }, | ||
| 1705 | variants: { | ||
| 1706 | dark: { | ||
| 1707 | ...createManagedThemeColors("dark", "#30231e", "#f39a62"), | ||
| 1708 | ...themeActionColors("#f78a7a"), | ||
| 1709 | }, | ||
| 1710 | }, | ||
| 1711 | sidebarArtwork: true, | ||
| 1712 | }; | ||
| 1713 | |||
| 1714 | export const IRIS_THEME: ThemeDefinition = { | ||
| 1715 | id: IRIS_THEME_ID, | ||
| 1716 | label: IRIS_THEME_LABEL, | ||
| 1717 | appearance: "light", | ||
| 1718 | colors: { | ||
| 1719 | ...createManagedThemeColors("light", "#f7f4fc", "#7254b9"), | ||
| 1720 | ...themeActionColors("#a82c87"), | ||
| 1721 | }, | ||
| 1722 | variants: { | ||
| 1723 | dark: { | ||
| 1724 | ...createManagedThemeColors("dark", "#29243b", "#ad92f5"), | ||
| 1725 | ...themeActionColors("#f099d8"), | ||
| 1726 | }, | ||
| 1727 | }, | ||
| 1728 | sidebarArtwork: true, | ||
| 1729 | }; | ||
| 1730 | |||
| 1731 | const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = [ | ||
| 1732 | T3_CHAT_THEME, | ||
| 1733 | GROVE_THEME, | ||
| 1734 | OCEAN_THEME, | ||
| 1735 | EMBER_THEME, | ||
| 1736 | IRIS_THEME, | ||
| 1737 | ]; | ||
| 1738 | 1421 | ||
| 1739 | export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null { | 1422 | export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null { |
| 1740 | const themeId = themeIdFromPreference(theme); | 1423 | const themeId = themeIdFromPreference(theme); |
docs/README.md+1| ... | @@ -8,6 +8,7 @@ | ... | @@ -8,6 +8,7 @@ |
| 8 | - [Organizing threads](./user/thread-sidebar.md) | 8 | - [Organizing threads](./user/thread-sidebar.md) |
| 9 | - [Review usage](./user/usage.md) | 9 | - [Review usage](./user/usage.md) |
| 10 | - [Customize a project icon](./user/project-settings.md) | 10 | - [Customize a project icon](./user/project-settings.md) |
| 11 | - [Mobile appearance](./user/mobile-appearance.md) | ||
| 11 | - [Remote access](./user/remote-access.md) | 12 | - [Remote access](./user/remote-access.md) |
| 12 | - [Keeping app and server in sync](./user/updating.md) | 13 | - [Keeping app and server in sync](./user/updating.md) |
| 13 | - [Source control integrations](./user/source-control.md) | 14 | - [Source control integrations](./user/source-control.md) |
docs/operations/mobile-app-store-screenshots.md+43-26| ... | @@ -30,7 +30,7 @@ The command: | ... | @@ -30,7 +30,7 @@ The command: |
| 30 | 4. Starts an isolated Metro server, builds the selected native apps, and boots each device. | 30 | 4. Starts an isolated Metro server, builds the selected native apps, and boots each device. |
| 31 | 5. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin. | 31 | 5. Pairs each clean app installation with Moonbase Terminal, Suspense Station, and Kernel Cabin. |
| 32 | 6. Navigates to the real application route for every requested scene. | 32 | 6. Navigates to the real application route for every requested scene. |
| 33 | 7. Sets the requested system appearance and normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and | 33 | 7. Sets the requested system appearance and palette, normalizes status bars, converts captures to 24-bit RGB PNGs without alpha, and |
| 34 | validates dimensions, aspect ratio, file size, and screenshot count before succeeding. | 34 | validates dimensions, aspect ratio, file size, and screenshot count before succeeding. |
| 35 | 8. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded | 35 | 8. Writes store-ready folders beneath `artifacts/app-store/screenshots/` that can be uploaded |
| 36 | directly to App Store Connect or Google Play Console. | 36 | directly to App Store Connect or Google Play Console. |
| ... | @@ -51,49 +51,60 @@ shared across every checkout. The readiness check only verifies that the port is | ... | @@ -51,49 +51,60 @@ shared across every checkout. The readiness check only verifies that the port is |
| 51 | verify process ownership. Concurrent screenshot harnesses in different worktrees can therefore | 51 | verify process ownership. Concurrent screenshot harnesses in different worktrees can therefore |
| 52 | collide or attach to the wrong Metro process. | 52 | collide or attach to the wrong Metro process. |
| 53 | 53 | ||
| 54 | Every configured device defaults to dark appearance, so plain `pnpm screenshots:mobile` produces | 54 | Every configured device defaults to dark appearance and the `t3-code` palette, so plain |
| 55 | 30 dark PNGs. Pass `--appearance light`, `--appearance dark`, or `--appearance both` to override the | 55 | `pnpm screenshots:mobile` produces 30 dark PNGs. Pass `--appearance light`, `--appearance dark`, or |
| 56 | configured appearance; `both` produces 60 PNGs. | 56 | `--appearance both` to override the configured appearance; `both` produces 60 PNGs. |
| 57 | |||
| 58 | Pass `--theme <id>` (repeatable) or `--theme all` to capture the app's other palettes: `t3-code`, | ||
| 59 | `t3-chat`, `grove`, `ocean`, `ember`, and `iris`. The runner hands the palette to the app as a launch | ||
| 60 | argument, the app applies it to both color schemes, and a scene only reports itself ready once the | ||
| 61 | requested palette is active — so a capture can never show the previous theme. `--theme all` | ||
| 62 | multiplies the run by six; only the native build is shared. | ||
| 57 | 63 | ||
| 58 | The default matrix is: | 64 | The default matrix is: |
| 59 | 65 | ||
| 60 | | Output folder | Capture target | Upload dimensions | Store slot | | 66 | | Output folder | Capture target | Upload dimensions | Store slot | |
| 61 | | ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- | | 67 | | ------------------------------------- | ------------------------- | ----------------- | ----------------------------------------- | |
| 62 | | `apple/iphone-6.9/dark/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | | 68 | | `apple/iphone-6.9/dark/t3-code/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | |
| 63 | | `apple/iphone-6.5/dark/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | | 69 | | `apple/iphone-6.5/dark/t3-code/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | |
| 64 | | `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape | | 70 | | `apple/ipad-13/dark/t3-code/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape | |
| 65 | | `google-play/phone/dark/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | | 71 | | `google-play/phone/dark/t3-code/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | |
| 66 | | `google-play/tablet-7/dark/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | | 72 | | `google-play/tablet-7/dark/t3-code/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | |
| 67 | | `google-play/tablet-10/dark/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | | 73 | | `google-play/tablet-10/dark/t3-code/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | |
| 68 | 74 | ||
| 69 | Each target captures thread, terminal, review, thread list, and environments. Each appearance | 75 | Each target captures thread, terminal, review, thread list, and environments. Each palette folder's |
| 70 | folder's five screenshots satisfy the configured Apple limit of 1–10, Google | 76 | five screenshots satisfy the configured Apple limit of 1–10, Google |
| 71 | phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8. | 77 | phone requirement of 2–8, and Google tablet recommendation/slot minimum of 4 with a maximum of 8. |
| 78 | Every palette gets its own leaf folder so one upload slot never mixes themes and each folder keeps a | ||
| 79 | store-legal screenshot count. | ||
| 72 | 80 | ||
| 73 | The generated tree is deliberately aligned with the store upload fields: | 81 | The generated tree is deliberately aligned with the store upload fields: |
| 74 | 82 | ||
| 75 | artifacts/app-store/screenshots/ | 83 | artifacts/app-store/screenshots/ |
| 76 | ├── apple/ | 84 | ├── apple/ |
| 77 | │ ├── iphone-6.9/dark/{thread,terminal,review,threads,environments}.png | 85 | │ ├── iphone-6.9/dark/t3-code/{thread,terminal,review,threads,environments}.png |
| 78 | │ ├── iphone-6.5/dark/{thread,terminal,review,threads,environments}.png | 86 | │ ├── iphone-6.5/dark/t3-code/{thread,terminal,review,threads,environments}.png |
| 79 | │ └── ipad-13/dark/{thread,terminal,review,threads,environments}.png | 87 | │ └── ipad-13/dark/t3-code/{thread,terminal,review,threads,environments}.png |
| 80 | └── google-play/ | 88 | └── google-play/ |
| 81 | ├── phone/dark/{thread,terminal,review,threads,environments}.png | 89 | ├── phone/dark/t3-code/{thread,terminal,review,threads,environments}.png |
| 82 | ├── tablet-7/dark/{thread,terminal,review,threads,environments}.png | 90 | ├── tablet-7/dark/t3-code/{thread,terminal,review,threads,environments}.png |
| 83 | └── tablet-10/dark/{thread,terminal,review,threads,environments}.png | 91 | └── tablet-10/dark/t3-code/{thread,terminal,review,threads,environments}.png |
| 84 | 92 | ||
| 85 | A light-only run writes the same tree under `light/`; `--appearance both` writes both appearance | 93 | A light-only run writes the same tree under `light/`; `--appearance both` writes both appearance |
| 86 | folders. | 94 | folders, and each requested theme adds a sibling folder next to `t3-code/`. |
| 87 | 95 | ||
| 88 | Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD | 96 | Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD |
| 89 | names, light/dark appearance, iOS orientation, scenes, output directory, capture delay, Android ABI, | 97 | names, light/dark appearance, default palette, iOS orientation, scenes, output directory, capture |
| 90 | or viewport. | 98 | delay, Android ABI, or viewport. The selectable palette ids come from `MOBILE_THEME_IDS` in |
| 99 | [themePalettes.ts](../../packages/shared/src/themePalettes.ts), so the harness and the app's | ||
| 100 | appearance settings can never drift apart. | ||
| 91 | 101 | ||
| 92 | ## Capture in GitHub Actions | 102 | ## Capture in GitHub Actions |
| 93 | 103 | ||
| 94 | Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab, choose `all`, `ios`, or | 104 | Run the `Mobile Showcase Screenshots` workflow from GitHub's Actions tab, choose `all`, `ios`, or |
| 95 | `android`, and select `light`, `dark`, or `both`. The default dispatch captures both appearances and | 105 | `android`, select `light`, `dark`, or `both`, and pick a palette (or `all`, which raises each job's |
| 96 | runs iOS and Android concurrently: iPhone and iPad capture on a | 106 | timeout from 60 to 300 minutes). The default dispatch captures both appearances of the `t3-code` |
| 107 | palette and runs iOS and Android concurrently: iPhone and iPad capture on a | ||
| 97 | 12-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a | 108 | 12-vCPU Blacksmith macOS runner, while Android phone, 7-inch tablet, and 10-inch tablet capture on a |
| 98 | 16-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator. | 109 | 16-vCPU Blacksmith Linux runner with a KVM-accelerated x86_64 emulator. |
| 99 | 110 | ||
| ... | @@ -120,6 +131,12 @@ Override the configured appearance or capture both variants: | ... | @@ -120,6 +131,12 @@ Override the configured appearance or capture both variants: |
| 120 | pnpm screenshots:mobile --appearance dark | 131 | pnpm screenshots:mobile --appearance dark |
| 121 | pnpm screenshots:mobile --appearance both | 132 | pnpm screenshots:mobile --appearance both |
| 122 | 133 | ||
| 134 | Capture other palettes: | ||
| 135 | |||
| 136 | pnpm screenshots:mobile --device iphone-6.9 --theme ocean | ||
| 137 | pnpm screenshots:mobile --device iphone-6.9 --theme ocean --theme ember | ||
| 138 | pnpm screenshots:mobile --device iphone-6.9 --theme all | ||
| 139 | |||
| 123 | Reuse the native build and retain the disposable environment: | 140 | Reuse the native build and retain the disposable environment: |
| 124 | 141 | ||
| 125 | pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running | 142 | pnpm screenshots:mobile --device ipad-13 --skip-build --keep-running |
docs/user/mobile-appearance.md created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | # Mobile appearance | ||
| 2 | |||
| 3 | T3 Code Mobile includes the T3 Code, T3 Chat, Grove, Ocean, Ember, and Iris themes. Each theme has | ||
| 4 | light and dark colors that apply throughout the app, including code reviews, file previews, the | ||
| 5 | terminal, native headers, and sheets. | ||
| 6 | |||
| 7 | To change themes: | ||
| 8 | |||
| 9 | 1. Open **Settings**. | ||
| 10 | 2. Select **Appearance**. | ||
| 11 | 3. Choose a theme. | ||
| 12 | 4. Select **System**, **Light**, or **Dark**. | ||
| 13 | |||
| 14 | **System** follows the device appearance automatically. Theme, text, code, and terminal appearance | ||
| 15 | preferences are stored on the device. | ||
packages/shared/package.json+8| ... | @@ -3,6 +3,14 @@ | ... | @@ -3,6 +3,14 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./themePalettes": { | ||
| 7 | "types": "./src/themePalettes.ts", | ||
| 8 | "import": "./src/themePalettes.ts" | ||
| 9 | }, | ||
| 10 | "./themePreview": { | ||
| 11 | "types": "./src/themePreview.ts", | ||
| 12 | "import": "./src/themePreview.ts" | ||
| 13 | }, | ||
| 6 | "./projectFavicon": { | 14 | "./projectFavicon": { |
| 7 | "types": "./src/projectFavicon.ts", | 15 | "types": "./src/projectFavicon.ts", |
| 8 | "import": "./src/projectFavicon.ts" | 16 | "import": "./src/projectFavicon.ts" |
packages/shared/src/themePalettes.ts created+748| ... | @@ -0,0 +1,748 @@ | ||
| 1 | export const BUILT_IN_THEME_IDS = ["t3-chat", "grove", "ocean", "ember", "iris"] as const; | ||
| 2 | |||
| 3 | /** The mobile app's own hand-tuned palette, which is not part of the built-in library. */ | ||
| 4 | export const MOBILE_DEFAULT_THEME_ID = "t3-code"; | ||
| 5 | |||
| 6 | /** | ||
| 7 | * Every palette the mobile app can render. Declared here so host-side tooling | ||
| 8 | * (the app-store screenshot harness) can validate a requested theme without | ||
| 9 | * importing React Native application code. | ||
| 10 | */ | ||
| 11 | export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const; | ||
| 12 | |||
| 13 | export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number]; | ||
| 14 | export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number]; | ||
| 15 | export type ThemeAppearance = "light" | "dark"; | ||
| 16 | |||
| 17 | /** Product roles shared by web CSS, React Native tokens, and native surfaces. */ | ||
| 18 | export const THEME_COLOR_ROLES = [ | ||
| 19 | "canvas", | ||
| 20 | "chrome", | ||
| 21 | "toolbar", | ||
| 22 | "toolbarForeground", | ||
| 23 | "toolbarBorder", | ||
| 24 | "toolbarControl", | ||
| 25 | "toolbarControlForeground", | ||
| 26 | "toolbarControlHover", | ||
| 27 | "surface", | ||
| 28 | "surfaceRaised", | ||
| 29 | "surfaceOverlay", | ||
| 30 | "text", | ||
| 31 | "textMuted", | ||
| 32 | "border", | ||
| 33 | "input", | ||
| 34 | "focus", | ||
| 35 | "accent", | ||
| 36 | "accentForeground", | ||
| 37 | "secondary", | ||
| 38 | "secondaryForeground", | ||
| 39 | "muted", | ||
| 40 | "mutedForeground", | ||
| 41 | "placeholder", | ||
| 42 | "secondaryLabel", | ||
| 43 | "iconMuted", | ||
| 44 | "error", | ||
| 45 | "errorForeground", | ||
| 46 | "errorSurface", | ||
| 47 | "warning", | ||
| 48 | "warningForeground", | ||
| 49 | "warningSurface", | ||
| 50 | "update", | ||
| 51 | "updateForeground", | ||
| 52 | "updateSurface", | ||
| 53 | "accentSurface", | ||
| 54 | "accentSurfaceForeground", | ||
| 55 | "messageSurface", | ||
| 56 | "messageForeground", | ||
| 57 | "messageAction", | ||
| 58 | "messageActionForeground", | ||
| 59 | "messageActionHover", | ||
| 60 | "codeBackground", | ||
| 61 | "codeForeground", | ||
| 62 | "sidebar", | ||
| 63 | "sidebarForeground", | ||
| 64 | "sidebarMutedForeground", | ||
| 65 | "sidebarControlSurface", | ||
| 66 | "sidebarRowHover", | ||
| 67 | "sidebarRowActive", | ||
| 68 | "sidebarRowSelected", | ||
| 69 | "sidebarBorder", | ||
| 70 | "terminalBackground", | ||
| 71 | "terminalForeground", | ||
| 72 | "terminalCursor", | ||
| 73 | "terminalSelection", | ||
| 74 | "terminalScrollbar", | ||
| 75 | "terminalScrollbarHover", | ||
| 76 | ] as const; | ||
| 77 | |||
| 78 | export type ThemeColorRole = (typeof THEME_COLOR_ROLES)[number]; | ||
| 79 | export type ThemeColors = Readonly<Record<ThemeColorRole, string>>; | ||
| 80 | export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>; | ||
| 81 | export type ThemeDefinition = Readonly<{ | ||
| 82 | id: string; | ||
| 83 | label: string; | ||
| 84 | appearance: ThemeAppearance; | ||
| 85 | colors: ThemeColors; | ||
| 86 | variants?: ThemeVariants; | ||
| 87 | /** Groups related imported variants into one library card. */ | ||
| 88 | collection?: Readonly<{ id: string; label: string }>; | ||
| 89 | /** Allows reviewed built-ins to render product artwork over their sidebar. */ | ||
| 90 | sidebarArtwork?: boolean; | ||
| 91 | /** Generated from the guided editor's canvas and accent roles. */ | ||
| 92 | managed?: boolean; | ||
| 93 | }>; | ||
| 94 | |||
| 95 | export const T3_CHAT_THEME: ThemeDefinition = { | ||
| 96 | id: "t3-chat", | ||
| 97 | label: "T3 Chat", | ||
| 98 | appearance: "light", | ||
| 99 | colors: { | ||
| 100 | canvas: "oklch(0.982446 0.010114 325.653)", | ||
| 101 | chrome: "oklch(0.982446 0.010114 325.653)", | ||
| 102 | toolbar: "oklch(0.982446 0.010114 325.653)", | ||
| 103 | toolbarForeground: "oklch(0.325698 0.116116 325.037)", | ||
| 104 | toolbarBorder: "oklch(0.856784 0.082879 328.911)", | ||
| 105 | toolbarControl: "oklch(0.939552 0.024286 321.664)", | ||
| 106 | toolbarControlForeground: "oklch(0.325698 0.116116 325.037)", | ||
| 107 | toolbarControlHover: "oklch(0.884525 0.041658 337.177)", | ||
| 108 | surface: "oklch(0.971835 0.012884 321.894)", | ||
| 109 | surfaceRaised: "oklch(0.988235 0.005049 325.615)", | ||
| 110 | surfaceOverlay: "oklch(1 0 0)", | ||
| 111 | text: "oklch(0.325698 0.116116 325.037)", | ||
| 112 | textMuted: "oklch(0.494754 0.190937 354.544)", | ||
| 113 | border: "oklch(0.923531 0.021247 328.096)", | ||
| 114 | input: "oklch(0.851713 0.055822 336.6)", | ||
| 115 | focus: "oklch(0.591646 0.217985 0.584)", | ||
| 116 | accent: "oklch(0.591646 0.217985 0.584)", | ||
| 117 | accentForeground: "oklch(1 0 0)", | ||
| 118 | secondary: "oklch(0.869588 0.06751 334.899)", | ||
| 119 | secondaryForeground: "oklch(0.444777 0.134061 324.799)", | ||
| 120 | muted: "oklch(0.802407 0.090963 345.892)", | ||
| 121 | mutedForeground: "oklch(0.428932 0.163929 354.332)", | ||
| 122 | placeholder: "oklch(0.549927 0.090215 323.149)", | ||
| 123 | secondaryLabel: "oklch(0.494754 0.190937 354.544)", | ||
| 124 | iconMuted: "oklch(0.494754 0.190937 354.544)", | ||
| 125 | error: "oklch(0.627117 0.248974 7.734)", | ||
| 126 | errorForeground: "oklch(0.458704 0.169677 3.815)", | ||
| 127 | errorSurface: "oklch(0.942787 0.032076 344.963)", | ||
| 128 | warning: "oklch(0.76859 0.164659 70.08)", | ||
| 129 | warningForeground: "oklch(0.54612 0.143036 48.949)", | ||
| 130 | warningSurface: "oklch(0.962901 0.015297 48.56)", | ||
| 131 | update: "oklch(0.591646 0.217985 0.584)", | ||
| 132 | updateForeground: "oklch(0.494754 0.190937 354.544)", | ||
| 133 | updateSurface: "oklch(0.930264 0.036194 341.45)", | ||
| 134 | accentSurface: "oklch(0.939552 0.024286 321.664)", | ||
| 135 | accentSurfaceForeground: "oklch(0.396296 0.025134 285.196)", | ||
| 136 | messageSurface: "oklch(0.926746 0.037898 332.6)", | ||
| 137 | messageForeground: "oklch(0.354591 0.093575 307.568)", | ||
| 138 | messageAction: "oklch(0.591646 0.217985 0.584)", | ||
| 139 | messageActionForeground: "oklch(1 0 0)", | ||
| 140 | messageActionHover: "oklch(0.539042 0.197866 0.305)", | ||
| 141 | codeBackground: "oklch(0.953855 0.019695 315.668)", | ||
| 142 | codeForeground: "oklch(0.445128 0.13005 307.026)", | ||
| 143 | sidebar: "oklch(0.928886 0.031178 322.592)", | ||
| 144 | sidebarForeground: "oklch(0.396296 0.025134 285.196)", | ||
| 145 | sidebarMutedForeground: "oklch(0.494754 0.190937 354.544)", | ||
| 146 | sidebarControlSurface: "oklch(0.978851 0.001321 106.424)", | ||
| 147 | sidebarRowHover: "oklch(0.978851 0.001321 106.424)", | ||
| 148 | sidebarRowActive: "oklch(0.978851 0.001321 106.424)", | ||
| 149 | sidebarRowSelected: "oklch(0.978851 0.001321 106.424)", | ||
| 150 | sidebarBorder: "oklch(0.938313 0.002552 48.717)", | ||
| 151 | terminalBackground: "oklch(0.982446 0.010114 325.653)", | ||
| 152 | terminalForeground: "oklch(0.325698 0.116116 325.037)", | ||
| 153 | terminalCursor: "oklch(0.591646 0.217985 0.584)", | ||
| 154 | terminalSelection: "oklch(0.869588 0.06751 334.899)", | ||
| 155 | terminalScrollbar: "oklch(0.851713 0.055822 336.6)", | ||
| 156 | terminalScrollbarHover: "oklch(0.802407 0.090963 345.892)", | ||
| 157 | }, | ||
| 158 | variants: { | ||
| 159 | dark: { | ||
| 160 | canvas: "oklch(0.22813 0.020366 307.469)", | ||
| 161 | chrome: "oklch(0.22813 0.020366 307.469)", | ||
| 162 | toolbar: "oklch(0.22813 0.020366 307.469)", | ||
| 163 | toolbarForeground: "oklch(0.980735 0.004092 301.426)", | ||
| 164 | toolbarBorder: "oklch(0.266943 0.015262 302.425)", | ||
| 165 | toolbarControl: "oklch(0.313674 0.030572 310.061)", | ||
| 166 | toolbarControlForeground: "oklch(0.848252 0.038248 307.961)", | ||
| 167 | toolbarControlHover: "oklch(0.364912 0.050794 308.491)", | ||
| 168 | surface: "oklch(0.267101 0.02016 311.799)", | ||
| 169 | surfaceRaised: "oklch(0.279864 0.021572 309.532)", | ||
| 170 | surfaceOverlay: "oklch(0.154761 0.01316 338.901)", | ||
| 171 | text: "oklch(0.980735 0.004092 301.426)", | ||
| 172 | textMuted: "oklch(0.880303 0.03077 342.696)", | ||
| 173 | border: "oklch(0.266943 0.015262 302.425)", | ||
| 174 | input: "oklch(0.266817 0.02897 344.461)", | ||
| 175 | focus: "oklch(0.591646 0.217985 0.584)", | ||
| 176 | accent: "oklch(0.460685 0.185347 4.099)", | ||
| 177 | accentForeground: "oklch(0.901233 0.057189 343.694)", | ||
| 178 | secondary: "oklch(0.313674 0.030572 310.061)", | ||
| 179 | secondaryForeground: "oklch(0.848252 0.038248 307.961)", | ||
| 180 | muted: "oklch(0.360924 0.021469 316.83)", | ||
| 181 | mutedForeground: "oklch(0.880303 0.03077 342.696)", | ||
| 182 | placeholder: "oklch(0.657087 0.028226 307.985)", | ||
| 183 | secondaryLabel: "oklch(0.880303 0.03077 342.696)", | ||
| 184 | iconMuted: "oklch(0.848252 0.038248 307.961)", | ||
| 185 | error: "oklch(0.458704 0.169677 3.815)", | ||
| 186 | errorForeground: "oklch(0.901233 0.057189 343.694)", | ||
| 187 | errorSurface: "oklch(0.259022 0.04799 340.062)", | ||
| 188 | warning: "oklch(0.76859 0.164659 70.08)", | ||
| 189 | warningForeground: "oklch(0.836861 0.164422 84.429)", | ||
| 190 | warningSurface: "oklch(0.321706 0.036256 60.806)", | ||
| 191 | update: "oklch(0.460685 0.185347 4.099)", | ||
| 192 | updateForeground: "oklch(0.901233 0.057189 343.694)", | ||
| 193 | updateSurface: "oklch(0.256077 0.063004 342.914)", | ||
| 194 | accentSurface: "oklch(0.364912 0.050794 308.491)", | ||
| 195 | accentSurfaceForeground: "oklch(0.964695 0.009139 341.803)", | ||
| 196 | messageSurface: "oklch(0.273791 0.025541 309.079)", | ||
| 197 | messageForeground: "oklch(0.949872 0.021269 306.838)", | ||
| 198 | messageAction: "oklch(0.460685 0.185347 4.099)", | ||
| 199 | messageActionForeground: "oklch(0.901233 0.057189 343.694)", | ||
| 200 | messageActionHover: "oklch(0.458754 0.184639 3.857)", | ||
| 201 | codeBackground: "oklch(0.22813 0.020366 307.469)", | ||
| 202 | codeForeground: "oklch(0.848703 0.064239 306.645)", | ||
| 203 | sidebar: "oklch(0.185778 0.019368 322.159)", | ||
| 204 | sidebarForeground: "oklch(0.967434 0.001326 286.375)", | ||
| 205 | sidebarMutedForeground: "oklch(0.880303 0.03077 342.696)", | ||
| 206 | sidebarControlSurface: "oklch(0.23366 0.026081 338.196)", | ||
| 207 | sidebarRowHover: "oklch(0.23366 0.026081 338.196)", | ||
| 208 | sidebarRowActive: "oklch(0.23366 0.026081 338.196)", | ||
| 209 | sidebarRowSelected: "oklch(0.23366 0.026081 338.196)", | ||
| 210 | sidebarBorder: "oklch(0.269132 0.030766 351.067)", | ||
| 211 | terminalBackground: "oklch(0.22813 0.020366 307.469)", | ||
| 212 | terminalForeground: "oklch(0.980735 0.004092 301.426)", | ||
| 213 | terminalCursor: "oklch(0.591646 0.217985 0.584)", | ||
| 214 | terminalSelection: "oklch(0.313674 0.030572 310.061)", | ||
| 215 | terminalScrollbar: "oklch(0.266817 0.02897 344.461)", | ||
| 216 | terminalScrollbarHover: "oklch(0.360924 0.021469 316.83)", | ||
| 217 | }, | ||
| 218 | }, | ||
| 219 | sidebarArtwork: true, | ||
| 220 | }; | ||
| 221 | |||
| 222 | export const GROVE_THEME: ThemeDefinition = { | ||
| 223 | id: "grove", | ||
| 224 | label: "Grove", | ||
| 225 | appearance: "light", | ||
| 226 | colors: { | ||
| 227 | canvas: "oklch(0.972369 0.005497 157.15)", | ||
| 228 | chrome: "oklch(0.972369 0.005497 157.15)", | ||
| 229 | toolbar: "oklch(0.972369 0.005497 157.15)", | ||
| 230 | toolbarForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 231 | toolbarBorder: "oklch(0.909438 0.021521 164.612)", | ||
| 232 | toolbarControl: "oklch(0.936464 0.014601 163.554)", | ||
| 233 | toolbarControlForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 234 | toolbarControlHover: "oklch(0.909438 0.021521 164.612)", | ||
| 235 | surface: "oklch(0.972369 0.005497 157.15)", | ||
| 236 | surfaceRaised: "oklch(0.949276 0.004496 159.002)", | ||
| 237 | surfaceOverlay: "oklch(0.932695 0.003778 160.944)", | ||
| 238 | text: "oklch(0.222003 0.03479 328.979)", | ||
| 239 | textMuted: "oklch(0.540472 0.014944 326.176)", | ||
| 240 | border: "oklch(0.864831 0.01312 167.255)", | ||
| 241 | input: "oklch(0.829746 0.016084 168.234)", | ||
| 242 | focus: "oklch(0.523295 0.112292 158.089)", | ||
| 243 | accent: "oklch(0.523295 0.112292 158.089)", | ||
| 244 | accentForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 245 | secondary: "oklch(0.936464 0.014601 163.554)", | ||
| 246 | secondaryForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 247 | muted: "oklch(0.945455 0.012308 162.879)", | ||
| 248 | mutedForeground: "oklch(0.527266 0.012309 320.683)", | ||
| 249 | placeholder: "oklch(0.529681 0.01551 326.299)", | ||
| 250 | secondaryLabel: "oklch(0.540472 0.014944 326.176)", | ||
| 251 | iconMuted: "oklch(0.540472 0.014944 326.176)", | ||
| 252 | error: "oklch(0.637823 0.237287 25.436)", | ||
| 253 | errorForeground: "oklch(0.509494 0.208583 28.513)", | ||
| 254 | errorSurface: "oklch(0.936968 0.014243 26.295)", | ||
| 255 | warning: "oklch(0.772406 0.172798 65.367)", | ||
| 256 | warningForeground: "oklch(0.545036 0.155019 45.359)", | ||
| 257 | warningSurface: "oklch(0.953175 0.02009 93.379)", | ||
| 258 | update: "oklch(0.523295 0.112292 158.089)", | ||
| 259 | updateForeground: "oklch(0.388012 0.080082 158.768)", | ||
| 260 | updateSurface: "oklch(0.900411 0.02384 164.795)", | ||
| 261 | accentSurface: "oklch(0.909438 0.021521 164.612)", | ||
| 262 | accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 263 | messageSurface: "oklch(0.891377 0.026164 164.929)", | ||
| 264 | messageForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 265 | messageAction: "oklch(0.535028 0.106403 77.549)", | ||
| 266 | messageActionForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 267 | messageActionHover: "oklch(0.488753 0.096536 77.829)", | ||
| 268 | codeBackground: "oklch(0.955888 0.004783 158.391)", | ||
| 269 | codeForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 270 | sidebar: "oklch(0.936464 0.014601 163.554)", | ||
| 271 | sidebarForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 272 | sidebarMutedForeground: "oklch(0.515606 0.011938 318.897)", | ||
| 273 | sidebarControlSurface: "oklch(0.88585 0.011734 166.331)", | ||
| 274 | sidebarRowHover: "oklch(0.886676 0.027374 164.983)", | ||
| 275 | sidebarRowActive: "oklch(0.85335 0.03597 165.158)", | ||
| 276 | sidebarRowSelected: "oklch(0.836654 0.040284 165.149)", | ||
| 277 | sidebarBorder: "oklch(0.860274 0.010287 168.339)", | ||
| 278 | terminalBackground: "oklch(0.972369 0.005497 157.15)", | ||
| 279 | terminalForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 280 | terminalCursor: "oklch(0.523295 0.112292 158.089)", | ||
| 281 | terminalSelection: "oklch(0.891377 0.026164 164.929)", | ||
| 282 | terminalScrollbar: "oklch(0.824752 0.001392 294.641)", | ||
| 283 | terminalScrollbarHover: "oklch(0.755495 0.004415 318.776)", | ||
| 284 | }, | ||
| 285 | variants: { | ||
| 286 | dark: { | ||
| 287 | canvas: "oklch(0.260865 0.02152 162.75)", | ||
| 288 | chrome: "oklch(0.260865 0.02152 162.75)", | ||
| 289 | toolbar: "oklch(0.260865 0.02152 162.75)", | ||
| 290 | toolbarForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 291 | toolbarBorder: "oklch(0.464636 0.066083 158.72)", | ||
| 292 | toolbarControl: "oklch(0.380487 0.048313 159.608)", | ||
| 293 | toolbarControlForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 294 | toolbarControlHover: "oklch(0.437021 0.060312 158.962)", | ||
| 295 | surface: "oklch(0.260865 0.02152 162.75)", | ||
| 296 | surfaceRaised: "oklch(0.363192 0.016572 165.32)", | ||
| 297 | surfaceOverlay: "oklch(0.411828 0.014378 166.627)", | ||
| 298 | text: "oklch(0.990339 0.008411 325.64)", | ||
| 299 | textMuted: "oklch(0.666747 0.004239 187.292)", | ||
| 300 | border: "oklch(0.457475 0.044046 160.971)", | ||
| 301 | input: "oklch(0.519849 0.049896 160.863)", | ||
| 302 | focus: "oklch(0.796228 0.133058 157.319)", | ||
| 303 | accent: "oklch(0.796228 0.133058 157.319)", | ||
| 304 | accentForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 305 | secondary: "oklch(0.380487 0.048313 159.608)", | ||
| 306 | secondaryForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 307 | muted: "oklch(0.339728 0.039456 160.274)", | ||
| 308 | mutedForeground: "oklch(0.715427 0.010896 171.428)", | ||
| 309 | placeholder: "oklch(0.739243 0.002222 223.225)", | ||
| 310 | secondaryLabel: "oklch(0.666747 0.004239 187.292)", | ||
| 311 | iconMuted: "oklch(0.666747 0.004239 187.292)", | ||
| 312 | error: "oklch(0.655108 0.221148 23.473)", | ||
| 313 | errorForeground: "oklch(0.704237 0.187511 22.228)", | ||
| 314 | errorSurface: "oklch(0.312773 0.02923 32.121)", | ||
| 315 | warning: "oklch(0.772406 0.172798 65.367)", | ||
| 316 | warningForeground: "oklch(0.829017 0.171221 81.038)", | ||
| 317 | warningSurface: "oklch(0.345524 0.046882 99.736)", | ||
| 318 | update: "oklch(0.796228 0.133058 157.319)", | ||
| 319 | updateForeground: "oklch(0.86276 0.089288 159.704)", | ||
| 320 | updateSurface: "oklch(0.448116 0.062637 158.86)", | ||
| 321 | accentSurface: "oklch(0.437021 0.060312 158.962)", | ||
| 322 | accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 323 | messageSurface: "oklch(0.470111 0.067221 158.676)", | ||
| 324 | messageForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 325 | messageAction: "oklch(0.791603 0.129713 83.299)", | ||
| 326 | messageActionForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 327 | messageActionHover: "oklch(0.815227 0.117902 84.21)", | ||
| 328 | codeBackground: "oklch(0.312979 0.018942 164.082)", | ||
| 329 | codeForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 330 | sidebar: "oklch(0.309925 0.032827 160.944)", | ||
| 331 | sidebarForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 332 | sidebarMutedForeground: "oklch(0.711387 0.007643 175.89)", | ||
| 333 | sidebarControlSurface: "oklch(0.432727 0.024549 163.654)", | ||
| 334 | sidebarRowHover: "oklch(0.374959 0.047124 159.686)", | ||
| 335 | sidebarRowActive: "oklch(0.41688 0.056069 159.165)", | ||
| 336 | sidebarRowSelected: "oklch(0.437466 0.060406 158.958)", | ||
| 337 | sidebarBorder: "oklch(0.569253 0.015933 167.062)", | ||
| 338 | terminalBackground: "oklch(0.260865 0.02152 162.75)", | ||
| 339 | terminalForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 340 | terminalCursor: "oklch(0.796228 0.133058 157.319)", | ||
| 341 | terminalSelection: "oklch(0.464636 0.066083 158.72)", | ||
| 342 | terminalScrollbar: "oklch(0.594692 0.006862 176.022)", | ||
| 343 | terminalScrollbarHover: "oklch(0.687968 0.00354 193.55)", | ||
| 344 | }, | ||
| 345 | }, | ||
| 346 | sidebarArtwork: true, | ||
| 347 | }; | ||
| 348 | |||
| 349 | export const OCEAN_THEME: ThemeDefinition = { | ||
| 350 | id: "ocean", | ||
| 351 | label: "Ocean", | ||
| 352 | appearance: "light", | ||
| 353 | colors: { | ||
| 354 | canvas: "oklch(0.974199 0.002856 241.597)", | ||
| 355 | chrome: "oklch(0.974199 0.002856 241.597)", | ||
| 356 | toolbar: "oklch(0.974199 0.002856 241.597)", | ||
| 357 | toolbarForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 358 | toolbarBorder: "oklch(0.91295 0.018827 241.836)", | ||
| 359 | toolbarControl: "oklch(0.939254 0.01193 241.729)", | ||
| 360 | toolbarControlForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 361 | toolbarControlHover: "oklch(0.91295 0.018827 241.836)", | ||
| 362 | surface: "oklch(0.974199 0.002856 241.597)", | ||
| 363 | surfaceRaised: "oklch(0.951058 0.002962 258.339)", | ||
| 364 | surfaceOverlay: "oklch(0.934442 0.003181 269.1)", | ||
| 365 | text: "oklch(0.222003 0.03479 328.979)", | ||
| 366 | textMuted: "oklch(0.541555 0.017468 323.531)", | ||
| 367 | border: "oklch(0.867646 0.013482 252.362)", | ||
| 368 | input: "oklch(0.832939 0.017389 252.598)", | ||
| 369 | focus: "oklch(0.536684 0.120219 247.01)", | ||
| 370 | accent: "oklch(0.536684 0.120219 247.01)", | ||
| 371 | accentForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 372 | secondary: "oklch(0.939254 0.01193 241.729)", | ||
| 373 | secondaryForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 374 | muted: "oklch(0.948004 0.009649 241.695)", | ||
| 375 | mutedForeground: "oklch(0.528741 0.01828 313.823)", | ||
| 376 | placeholder: "oklch(0.530733 0.01795 323.79)", | ||
| 377 | secondaryLabel: "oklch(0.541555 0.017468 323.531)", | ||
| 378 | iconMuted: "oklch(0.541555 0.017468 323.531)", | ||
| 379 | error: "oklch(0.637823 0.237287 25.436)", | ||
| 380 | errorForeground: "oklch(0.509494 0.208583 28.513)", | ||
| 381 | errorSurface: "oklch(0.938747 0.016377 7.186)", | ||
| 382 | warning: "oklch(0.772406 0.172798 65.367)", | ||
| 383 | warningForeground: "oklch(0.546927 0.155556 45.359)", | ||
| 384 | warningSurface: "oklch(0.954846 0.016009 81.731)", | ||
| 385 | update: "oklch(0.536684 0.120219 247.01)", | ||
| 386 | updateForeground: "oklch(0.397497 0.084999 246.523)", | ||
| 387 | updateSurface: "oklch(0.904165 0.021144 241.874)", | ||
| 388 | accentSurface: "oklch(0.91295 0.018827 241.836)", | ||
| 389 | accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 390 | messageSurface: "oklch(0.895373 0.023469 241.913)", | ||
| 391 | messageForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 392 | messageAction: "oklch(0.493961 0.08175 201.584)", | ||
| 393 | messageActionForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 394 | messageActionHover: "oklch(0.45151 0.074407 201.516)", | ||
| 395 | codeBackground: "oklch(0.957684 0.002906 253.68)", | ||
| 396 | codeForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 397 | sidebar: "oklch(0.939254 0.01193 241.729)", | ||
| 398 | sidebarForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 399 | sidebarMutedForeground: "oklch(0.517366 0.018944 311.433)", | ||
| 400 | sidebarControlSurface: "oklch(0.888479 0.011475 251.638)", | ||
| 401 | sidebarRowHover: "oklch(0.890798 0.024681 241.933)", | ||
| 402 | sidebarRowActive: "oklch(0.858363 0.033325 242.089)", | ||
| 403 | sidebarRowSelected: "oklch(0.842113 0.037689 242.174)", | ||
| 404 | sidebarBorder: "oklch(0.862823 0.011384 256.926)", | ||
| 405 | terminalBackground: "oklch(0.974199 0.002856 241.597)", | ||
| 406 | terminalForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 407 | terminalCursor: "oklch(0.536684 0.120219 247.01)", | ||
| 408 | terminalSelection: "oklch(0.895373 0.023469 241.913)", | ||
| 409 | terminalScrollbar: "oklch(0.826271 0.006191 305.456)", | ||
| 410 | terminalScrollbarHover: "oklch(0.756866 0.008685 313.721)", | ||
| 411 | }, | ||
| 412 | variants: { | ||
| 413 | dark: { | ||
| 414 | canvas: "oklch(0.242641 0.024125 250.573)", | ||
| 415 | chrome: "oklch(0.242641 0.024125 250.573)", | ||
| 416 | toolbar: "oklch(0.242641 0.024125 250.573)", | ||
| 417 | toolbarForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 418 | toolbarBorder: "oklch(0.439946 0.0561 243.479)", | ||
| 419 | toolbarControl: "oklch(0.358725 0.043145 244.911)", | ||
| 420 | toolbarControlForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 421 | toolbarControlHover: "oklch(0.413315 0.051874 243.855)", | ||
| 422 | surface: "oklch(0.242641 0.024125 250.573)", | ||
| 423 | surfaceRaised: "oklch(0.348439 0.019942 253.696)", | ||
| 424 | surfaceOverlay: "oklch(0.398517 0.018232 255.72)", | ||
| 425 | text: "oklch(0.990339 0.008411 325.64)", | ||
| 426 | textMuted: "oklch(0.652227 0.01149 273.31)", | ||
| 427 | border: "oklch(0.438653 0.039496 245.44)", | ||
| 428 | input: "oklch(0.500905 0.043574 244.781)", | ||
| 429 | focus: "oklch(0.758933 0.105833 241.548)", | ||
| 430 | accent: "oklch(0.758933 0.105833 241.548)", | ||
| 431 | accentForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 432 | secondary: "oklch(0.358725 0.043145 244.911)", | ||
| 433 | secondaryForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 434 | muted: "oklch(0.319287 0.036766 246.065)", | ||
| 435 | mutedForeground: "oklch(0.691936 0.016294 261.588)", | ||
| 436 | placeholder: "oklch(0.721641 0.010192 281.271)", | ||
| 437 | secondaryLabel: "oklch(0.652227 0.01149 273.31)", | ||
| 438 | iconMuted: "oklch(0.652227 0.01149 273.31)", | ||
| 439 | error: "oklch(0.655108 0.221148 23.473)", | ||
| 440 | errorForeground: "oklch(0.702184 0.189226 22.228)", | ||
| 441 | errorSurface: "oklch(0.298933 0.036443 350.094)", | ||
| 442 | warning: "oklch(0.772406 0.172798 65.367)", | ||
| 443 | warningForeground: "oklch(0.829017 0.171221 81.038)", | ||
| 444 | warningSurface: "oklch(0.329449 0.028712 84.495)", | ||
| 445 | update: "oklch(0.758933 0.105833 241.548)", | ||
| 446 | updateForeground: "oklch(0.840844 0.069217 240.151)", | ||
| 447 | updateSurface: "oklch(0.424017 0.053575 243.695)", | ||
| 448 | accentSurface: "oklch(0.413315 0.051874 243.855)", | ||
| 449 | accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 450 | messageSurface: "oklch(0.445224 0.056936 243.413)", | ||
| 451 | messageForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 452 | messageAction: "oklch(0.793363 0.105022 199.893)", | ||
| 453 | messageActionForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 454 | messageActionHover: "oklch(0.815308 0.096174 199.862)", | ||
| 455 | codeBackground: "oklch(0.29661 0.021883 251.968)", | ||
| 456 | codeForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 457 | sidebar: "oklch(0.290387 0.032043 247.274)", | ||
| 458 | sidebarForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 459 | sidebarMutedForeground: "oklch(0.69099 0.01395 266.424)", | ||
| 460 | sidebarControlSurface: "oklch(0.417822 0.02535 250.162)", | ||
| 461 | sidebarRowHover: "oklch(0.353381 0.042285 245.043)", | ||
| 462 | sidebarRowActive: "oklch(0.393878 0.048778 244.179)", | ||
| 463 | sidebarRowSelected: "oklch(0.413744 0.051943 243.848)", | ||
| 464 | sidebarBorder: "oklch(0.55859 0.019001 256.223)", | ||
| 465 | terminalBackground: "oklch(0.242641 0.024125 250.573)", | ||
| 466 | terminalForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 467 | terminalCursor: "oklch(0.758933 0.105833 241.548)", | ||
| 468 | terminalSelection: "oklch(0.439946 0.0561 243.479)", | ||
| 469 | terminalScrollbar: "oklch(0.58613 0.012959 267.22)", | ||
| 470 | terminalScrollbarHover: "oklch(0.681569 0.010909 276.465)", | ||
| 471 | }, | ||
| 472 | }, | ||
| 473 | sidebarArtwork: true, | ||
| 474 | }; | ||
| 475 | |||
| 476 | export const EMBER_THEME: ThemeDefinition = { | ||
| 477 | id: "ember", | ||
| 478 | label: "Ember", | ||
| 479 | appearance: "light", | ||
| 480 | colors: { | ||
| 481 | canvas: "oklch(0.976527 0.002685 60.725)", | ||
| 482 | chrome: "oklch(0.976527 0.002685 60.725)", | ||
| 483 | toolbar: "oklch(0.976527 0.002685 60.725)", | ||
| 484 | toolbarForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 485 | toolbarBorder: "oklch(0.916502 0.01832 49.597)", | ||
| 486 | toolbarControl: "oklch(0.942267 0.01151 50.785)", | ||
| 487 | toolbarControlForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 488 | toolbarControlHover: "oklch(0.916502 0.01832 49.597)", | ||
| 489 | surface: "oklch(0.976527 0.002685 60.725)", | ||
| 490 | surfaceRaised: "oklch(0.953321 0.002701 42.266)", | ||
| 491 | surfaceOverlay: "oklch(0.936659 0.002879 29.96)", | ||
| 492 | text: "oklch(0.222003 0.03479 328.979)", | ||
| 493 | textMuted: "oklch(0.543023 0.017316 331.964)", | ||
| 494 | border: "oklch(0.870631 0.013204 39.431)", | ||
| 495 | input: "oklch(0.836213 0.017153 38.661)", | ||
| 496 | focus: "oklch(0.552831 0.129438 44.656)", | ||
| 497 | accent: "oklch(0.552831 0.129438 44.656)", | ||
| 498 | accentForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 499 | secondary: "oklch(0.942267 0.01151 50.785)", | ||
| 500 | secondaryForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 501 | muted: "oklch(0.950842 0.009273 51.528)", | ||
| 502 | mutedForeground: "oklch(0.530413 0.018453 341.181)", | ||
| 503 | placeholder: "oklch(0.532339 0.017796 331.748)", | ||
| 504 | secondaryLabel: "oklch(0.543023 0.017316 331.964)", | ||
| 505 | iconMuted: "oklch(0.543023 0.017316 331.964)", | ||
| 506 | error: "oklch(0.637823 0.237287 25.436)", | ||
| 507 | errorForeground: "oklch(0.509494 0.208583 28.513)", | ||
| 508 | errorSurface: "oklch(0.941094 0.019938 19.375)", | ||
| 509 | warning: "oklch(0.772406 0.172798 65.367)", | ||
| 510 | warningForeground: "oklch(0.549154 0.156188 45.359)", | ||
| 511 | warningSurface: "oklch(0.957148 0.020843 76.702)", | ||
| 512 | update: "oklch(0.552831 0.129438 44.656)", | ||
| 513 | updateForeground: "oklch(0.408647 0.091207 45.037)", | ||
| 514 | updateSurface: "oklch(0.907902 0.020621 49.36)", | ||
| 515 | accentSurface: "oklch(0.916502 0.01832 49.597)", | ||
| 516 | accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 517 | messageSurface: "oklch(0.899296 0.022939 49.163)", | ||
| 518 | messageForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 519 | messageAction: "oklch(0.516323 0.161628 24.82)", | ||
| 520 | messageActionForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 521 | messageActionHover: "oklch(0.471223 0.145843 24.688)", | ||
| 522 | codeBackground: "oklch(0.959965 0.002668 47.512)", | ||
| 523 | codeForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 524 | sidebar: "oklch(0.942267 0.01151 50.785)", | ||
| 525 | sidebarForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 526 | sidebarMutedForeground: "oklch(0.519146 0.019214 343.427)", | ||
| 527 | sidebarControlSurface: "oklch(0.891332 0.011179 40.596)", | ||
| 528 | sidebarRowHover: "oklch(0.894819 0.024151 49.073)", | ||
| 529 | sidebarRowActive: "oklch(0.863104 0.032855 48.586)", | ||
| 530 | sidebarRowSelected: "oklch(0.84723 0.037292 48.403)", | ||
| 531 | sidebarBorder: "oklch(0.865593 0.011154 35.246)", | ||
| 532 | terminalBackground: "oklch(0.976527 0.002685 60.725)", | ||
| 533 | terminalForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 534 | terminalCursor: "oklch(0.552831 0.129438 44.656)", | ||
| 535 | terminalSelection: "oklch(0.899296 0.022939 49.163)", | ||
| 536 | terminalScrollbar: "oklch(0.828185 0.005884 349.533)", | ||
| 537 | terminalScrollbarHover: "oklch(0.758584 0.008423 341.16)", | ||
| 538 | }, | ||
| 539 | variants: { | ||
| 540 | dark: { | ||
| 541 | canvas: "oklch(0.245899 0.019144 42.044)", | ||
| 542 | chrome: "oklch(0.245899 0.019144 42.044)", | ||
| 543 | toolbar: "oklch(0.245899 0.019144 42.044)", | ||
| 544 | toolbarForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 545 | toolbarBorder: "oklch(0.442681 0.0608 50.795)", | ||
| 546 | toolbarControl: "oklch(0.361499 0.044052 49.515)", | ||
| 547 | toolbarControlForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 548 | toolbarControlHover: "oklch(0.416048 0.055354 50.484)", | ||
| 549 | surface: "oklch(0.245899 0.019144 42.044)", | ||
| 550 | surfaceRaised: "oklch(0.351262 0.01565 37.592)", | ||
| 551 | surfaceOverlay: "oklch(0.401111 0.014308 34.896)", | ||
| 552 | text: "oklch(0.990339 0.008411 325.64)", | ||
| 553 | textMuted: "oklch(0.654017 0.009505 13.287)", | ||
| 554 | border: "oklch(0.44099 0.040202 48.807)", | ||
| 555 | input: "oklch(0.503003 0.045721 49.44)", | ||
| 556 | focus: "oklch(0.762174 0.124117 52.082)", | ||
| 557 | accent: "oklch(0.762174 0.124117 52.082)", | ||
| 558 | accentForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 559 | secondary: "oklch(0.361499 0.044052 49.515)", | ||
| 560 | secondaryForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 561 | muted: "oklch(0.322144 0.03574 48.309)", | ||
| 562 | mutedForeground: "oklch(0.692479 0.015227 30.963)", | ||
| 563 | placeholder: "oklch(0.723533 0.008741 4.515)", | ||
| 564 | secondaryLabel: "oklch(0.654017 0.009505 13.287)", | ||
| 565 | iconMuted: "oklch(0.654017 0.009505 13.287)", | ||
| 566 | error: "oklch(0.655108 0.221148 23.473)", | ||
| 567 | errorForeground: "oklch(0.702184 0.189226 22.228)", | ||
| 568 | errorSurface: "oklch(0.310955 0.059624 24.334)", | ||
| 569 | warning: "oklch(0.772406 0.172798 65.367)", | ||
| 570 | warningForeground: "oklch(0.829017 0.171221 81.038)", | ||
| 571 | warningSurface: "oklch(0.339137 0.055638 66.911)", | ||
| 572 | update: "oklch(0.762174 0.124117 52.082)", | ||
| 573 | updateForeground: "oklch(0.841456 0.079585 53.521)", | ||
| 574 | updateSurface: "oklch(0.426749 0.057547 50.618)", | ||
| 575 | accentSurface: "oklch(0.416048 0.055354 50.484)", | ||
| 576 | accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 577 | messageSurface: "oklch(0.447961 0.061874 50.849)", | ||
| 578 | messageForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 579 | messageAction: "oklch(0.747955 0.135578 29.432)", | ||
| 580 | messageActionForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 581 | messageActionHover: "oklch(0.775116 0.117953 29.014)", | ||
| 582 | codeBackground: "oklch(0.299662 0.017229 39.973)", | ||
| 583 | codeForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 584 | sidebar: "oklch(0.293349 0.029554 46.882)", | ||
| 585 | sidebarForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 586 | sidebarMutedForeground: "oklch(0.691874 0.012538 24.638)", | ||
| 587 | sidebarControlSurface: "oklch(0.420227 0.022893 43.226)", | ||
| 588 | sidebarRowHover: "oklch(0.356163 0.042933 49.385)", | ||
| 589 | sidebarRowActive: "oklch(0.396617 0.051353 50.201)", | ||
| 590 | sidebarRowSelected: "oklch(0.416477 0.055442 50.489)", | ||
| 591 | sidebarBorder: "oklch(0.560372 0.016998 36.179)", | ||
| 592 | terminalBackground: "oklch(0.245899 0.019144 42.044)", | ||
| 593 | terminalForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 594 | terminalCursor: "oklch(0.762174 0.124117 52.082)", | ||
| 595 | terminalSelection: "oklch(0.442681 0.0608 50.795)", | ||
| 596 | terminalScrollbar: "oklch(0.587861 0.010463 20.444)", | ||
| 597 | terminalScrollbarHover: "oklch(0.682876 0.009156 9.796)", | ||
| 598 | }, | ||
| 599 | }, | ||
| 600 | sidebarArtwork: true, | ||
| 601 | }; | ||
| 602 | |||
| 603 | export const IRIS_THEME: ThemeDefinition = { | ||
| 604 | id: "iris", | ||
| 605 | label: "Iris", | ||
| 606 | appearance: "light", | ||
| 607 | colors: { | ||
| 608 | canvas: "oklch(0.976531 0.003855 303.226)", | ||
| 609 | chrome: "oklch(0.976531 0.003855 303.226)", | ||
| 610 | toolbar: "oklch(0.976531 0.003855 303.226)", | ||
| 611 | toolbarForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 612 | toolbarBorder: "oklch(0.914882 0.022965 299.986)", | ||
| 613 | toolbarControl: "oklch(0.941387 0.014687 300.474)", | ||
| 614 | toolbarControlForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 615 | toolbarControlHover: "oklch(0.914882 0.022965 299.986)", | ||
| 616 | surface: "oklch(0.976531 0.003855 303.226)", | ||
| 617 | surfaceRaised: "oklch(0.953326 0.004536 307.676)", | ||
| 618 | surfaceOverlay: "oklch(0.936665 0.005041 310.132)", | ||
| 619 | text: "oklch(0.222003 0.03479 328.979)", | ||
| 620 | textMuted: "oklch(0.543042 0.018894 325.652)", | ||
| 621 | border: "oklch(0.869608 0.018226 303.859)", | ||
| 622 | input: "oklch(0.834773 0.023405 303.676)", | ||
| 623 | focus: "oklch(0.525348 0.15373 294.176)", | ||
| 624 | accent: "oklch(0.525348 0.15373 294.176)", | ||
| 625 | accentForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 626 | secondary: "oklch(0.941387 0.014687 300.474)", | ||
| 627 | secondaryForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 628 | muted: "oklch(0.950194 0.011956 300.733)", | ||
| 629 | mutedForeground: "oklch(0.529955 0.022319 321.556)", | ||
| 630 | placeholder: "oklch(0.532177 0.019333 325.784)", | ||
| 631 | secondaryLabel: "oklch(0.543042 0.018894 325.652)", | ||
| 632 | iconMuted: "oklch(0.543042 0.018894 325.652)", | ||
| 633 | error: "oklch(0.637823 0.237287 25.436)", | ||
| 634 | errorForeground: "oklch(0.509494 0.208583 28.513)", | ||
| 635 | errorSurface: "oklch(0.941043 0.019582 4.235)", | ||
| 636 | warning: "oklch(0.772406 0.172798 65.367)", | ||
| 637 | warningForeground: "oklch(0.549154 0.156188 45.359)", | ||
| 638 | warningSurface: "oklch(0.957054 0.016197 69.932)", | ||
| 639 | update: "oklch(0.525348 0.15373 294.176)", | ||
| 640 | updateForeground: "oklch(0.389926 0.10825 294.547)", | ||
| 641 | updateSurface: "oklch(0.90602 0.025754 299.867)", | ||
| 642 | accentSurface: "oklch(0.914882 0.022965 299.986)", | ||
| 643 | accentSurfaceForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 644 | messageSurface: "oklch(0.897143 0.028558 299.758)", | ||
| 645 | messageForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 646 | messageAction: "oklch(0.516084 0.185229 340.776)", | ||
| 647 | messageActionForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 648 | messageActionHover: "oklch(0.471003 0.16748 340.687)", | ||
| 649 | codeBackground: "oklch(0.95997 0.004338 306.542)", | ||
| 650 | codeForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 651 | sidebar: "oklch(0.941387 0.014687 300.474)", | ||
| 652 | sidebarForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 653 | sidebarMutedForeground: "oklch(0.518417 0.023683 320.681)", | ||
| 654 | sidebarControlSurface: "oklch(0.890512 0.0155 303.803)", | ||
| 655 | sidebarRowHover: "oklch(0.892522 0.030022 299.704)", | ||
| 656 | sidebarRowActive: "oklch(0.85971 0.040501 299.36)", | ||
| 657 | sidebarRowSelected: "oklch(0.843236 0.045818 299.198)", | ||
| 658 | sidebarBorder: "oklch(0.864805 0.015938 305.371)", | ||
| 659 | terminalBackground: "oklch(0.976531 0.003855 303.226)", | ||
| 660 | terminalForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 661 | terminalCursor: "oklch(0.525348 0.15373 294.176)", | ||
| 662 | terminalSelection: "oklch(0.897143 0.028558 299.758)", | ||
| 663 | terminalScrollbar: "oklch(0.828195 0.008526 318.858)", | ||
| 664 | terminalScrollbarHover: "oklch(0.758596 0.010892 321.538)", | ||
| 665 | }, | ||
| 666 | variants: { | ||
| 667 | dark: { | ||
| 668 | canvas: "oklch(0.225975 0.031062 293.741)", | ||
| 669 | chrome: "oklch(0.225975 0.031062 293.741)", | ||
| 670 | toolbar: "oklch(0.225975 0.031062 293.741)", | ||
| 671 | toolbarForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 672 | toolbarBorder: "oklch(0.395417 0.085554 294.182)", | ||
| 673 | toolbarControl: "oklch(0.325405 0.063614 294.23)", | ||
| 674 | toolbarControlForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 675 | toolbarControlHover: "oklch(0.372436 0.07841 294.204)", | ||
| 676 | surface: "oklch(0.225975 0.031062 293.741)", | ||
| 677 | surfaceRaised: "oklch(0.335291 0.026008 296.394)", | ||
| 678 | surfaceOverlay: "oklch(0.386739 0.024023 297.509)", | ||
| 679 | text: "oklch(0.990339 0.008411 325.64)", | ||
| 680 | textMuted: "oklch(0.640465 0.016197 304.171)", | ||
| 681 | border: "oklch(0.40874 0.058536 295.893)", | ||
| 682 | input: "oklch(0.46756 0.065775 296.265)", | ||
| 683 | focus: "oklch(0.671712 0.169136 293.929)", | ||
| 684 | accent: "oklch(0.671712 0.169136 293.929)", | ||
| 685 | accentForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 686 | secondary: "oklch(0.325405 0.063614 294.23)", | ||
| 687 | secondaryForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 688 | muted: "oklch(0.291515 0.05276 294.209)", | ||
| 689 | mutedForeground: "oklch(0.663321 0.025932 301.862)", | ||
| 690 | placeholder: "oklch(0.706249 0.014508 306.607)", | ||
| 691 | secondaryLabel: "oklch(0.640465 0.016197 304.171)", | ||
| 692 | iconMuted: "oklch(0.640465 0.016197 304.171)", | ||
| 693 | error: "oklch(0.655108 0.221148 23.473)", | ||
| 694 | errorForeground: "oklch(0.702184 0.189226 22.228)", | ||
| 695 | errorSurface: "oklch(0.291658 0.054707 352.238)", | ||
| 696 | warning: "oklch(0.772406 0.172798 65.367)", | ||
| 697 | warningForeground: "oklch(0.829017 0.171221 81.038)", | ||
| 698 | warningSurface: "oklch(0.318952 0.033845 51.646)", | ||
| 699 | update: "oklch(0.671712 0.169136 293.929)", | ||
| 700 | updateForeground: "oklch(0.785032 0.108439 296.344)", | ||
| 701 | updateSurface: "oklch(0.381668 0.081286 294.195)", | ||
| 702 | accentSurface: "oklch(0.372436 0.07841 294.204)", | ||
| 703 | accentSurfaceForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 704 | messageSurface: "oklch(0.399975 0.086965 294.177)", | ||
| 705 | messageForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 706 | messageAction: "oklch(0.789904 0.130063 337.621)", | ||
| 707 | messageActionForeground: "oklch(0.222003 0.03479 328.979)", | ||
| 708 | messageActionHover: "oklch(0.813537 0.114101 337.23)", | ||
| 709 | codeBackground: "oklch(0.281873 0.028308 295.193)", | ||
| 710 | codeForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 711 | sidebar: "oklch(0.266743 0.044689 294.138)", | ||
| 712 | sidebarForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 713 | sidebarMutedForeground: "oklch(0.668773 0.021522 302.949)", | ||
| 714 | sidebarControlSurface: "oklch(0.399977 0.035678 297.031)", | ||
| 715 | sidebarRowHover: "oklch(0.320808 0.062152 294.23)", | ||
| 716 | sidebarRowActive: "oklch(0.355677 0.073167 294.217)", | ||
| 717 | sidebarRowSelected: "oklch(0.372806 0.078525 294.203)", | ||
| 718 | sidebarBorder: "oklch(0.545895 0.027522 299.871)", | ||
| 719 | terminalBackground: "oklch(0.225975 0.031062 293.741)", | ||
| 720 | terminalForeground: "oklch(0.990339 0.008411 325.64)", | ||
| 721 | terminalCursor: "oklch(0.671712 0.169136 293.929)", | ||
| 722 | terminalSelection: "oklch(0.395417 0.085554 294.182)", | ||
| 723 | terminalScrollbar: "oklch(0.578663 0.017888 302.229)", | ||
| 724 | terminalScrollbarHover: "oklch(0.676012 0.015271 305.433)", | ||
| 725 | }, | ||
| 726 | }, | ||
| 727 | sidebarArtwork: true, | ||
| 728 | }; | ||
| 729 | |||
| 730 | export const BUILT_IN_THEMES: ReadonlyArray<ThemeDefinition> = [ | ||
| 731 | T3_CHAT_THEME, | ||
| 732 | GROVE_THEME, | ||
| 733 | OCEAN_THEME, | ||
| 734 | EMBER_THEME, | ||
| 735 | IRIS_THEME, | ||
| 736 | ]; | ||
| 737 | |||
| 738 | export function getBuiltInTheme(id: string): ThemeDefinition | null { | ||
| 739 | return BUILT_IN_THEMES.find((theme) => theme.id === id) ?? null; | ||
| 740 | } | ||
| 741 | |||
| 742 | export function getThemeColorsForAppearance( | ||
| 743 | theme: ThemeDefinition, | ||
| 744 | appearance: ThemeAppearance, | ||
| 745 | ): ThemeColors | null { | ||
| 746 | if (theme.appearance === appearance) return theme.colors; | ||
| 747 | return theme.variants?.[appearance] ?? null; | ||
| 748 | } | ||
packages/shared/src/themePreview.test.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | mixThemePreviewBase, | ||
| 5 | STANDARD_THEME_PREVIEW_COLORS, | ||
| 6 | THEME_PREVIEW_RENDER_SPECS, | ||
| 7 | } from "./themePreview.js"; | ||
| 8 | |||
| 9 | describe("theme preview", () => { | ||
| 10 | it("keeps the desktop preview geometry stable across clients", () => { | ||
| 11 | expect(THEME_PREVIEW_RENDER_SPECS.light!.accent.center).toEqual([0.72, 0.22]); | ||
| 12 | expect(THEME_PREVIEW_RENDER_SPECS.dark!.accent.middleOpacity).toBe(0.62); | ||
| 13 | expect(THEME_PREVIEW_RENDER_SPECS.dark!.action.center).toEqual([0.82, 0.18]); | ||
| 14 | }); | ||
| 15 | |||
| 16 | it("mixes the standard canvas bases in OKLab", () => { | ||
| 17 | expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.light!, "light")).toBe("#fdfdfd"); | ||
| 18 | expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.dark!, "dark")).toBe("#0a0a0a"); | ||
| 19 | }); | ||
| 20 | }); | ||
packages/shared/src/themePreview.ts created+145| ... | @@ -0,0 +1,145 @@ | ||
| 1 | import type { ThemeAppearance } from "./themePalettes.js"; | ||
| 2 | |||
| 3 | export type ThemePreviewColors = Readonly<{ | ||
| 4 | canvas: string; | ||
| 5 | accent: string; | ||
| 6 | messageAction: string; | ||
| 7 | }>; | ||
| 8 | |||
| 9 | /** The standard T3 Code artwork is not a built-in theme, so its preview colors live here. */ | ||
| 10 | export const STANDARD_THEME_PREVIEW_COLORS: Readonly<Record<ThemeAppearance, ThemePreviewColors>> = | ||
| 11 | { | ||
| 12 | light: { | ||
| 13 | canvas: "#fcfcfc", | ||
| 14 | accent: "#f4f4f5", | ||
| 15 | messageAction: "#4f46e5", | ||
| 16 | }, | ||
| 17 | dark: { | ||
| 18 | canvas: "#0a0a0a", | ||
| 19 | accent: "#1c1c1f", | ||
| 20 | messageAction: "#8b9cff", | ||
| 21 | }, | ||
| 22 | }; | ||
| 23 | |||
| 24 | export type ThemePreviewRenderSpec = Readonly<{ | ||
| 25 | baseTarget: string; | ||
| 26 | baseWeight: number; | ||
| 27 | accent: Readonly<{ | ||
| 28 | center: readonly [x: number, y: number]; | ||
| 29 | middleOffset: number; | ||
| 30 | middleOpacity: number; | ||
| 31 | endOffset: number; | ||
| 32 | }>; | ||
| 33 | action: Readonly<{ | ||
| 34 | center: readonly [x: number, y: number]; | ||
| 35 | startOpacity: number; | ||
| 36 | endOffset: number; | ||
| 37 | }>; | ||
| 38 | scale: number; | ||
| 39 | blurAt56Px: number; | ||
| 40 | }>; | ||
| 41 | |||
| 42 | /** Shared geometry and falloff for the web and native theme preview orbs. */ | ||
| 43 | export const THEME_PREVIEW_RENDER_SPECS: Readonly<Record<ThemeAppearance, ThemePreviewRenderSpec>> = | ||
| 44 | { | ||
| 45 | light: { | ||
| 46 | baseTarget: "#ffffff", | ||
| 47 | baseWeight: 0.8, | ||
| 48 | accent: { | ||
| 49 | center: [0.72, 0.22], | ||
| 50 | middleOffset: 0.28, | ||
| 51 | middleOpacity: 0.72, | ||
| 52 | endOffset: 0.58, | ||
| 53 | }, | ||
| 54 | action: { | ||
| 55 | center: [0.18, 0.82], | ||
| 56 | startOpacity: 0.45, | ||
| 57 | endOffset: 0.55, | ||
| 58 | }, | ||
| 59 | scale: 1.1, | ||
| 60 | blurAt56Px: 3, | ||
| 61 | }, | ||
| 62 | dark: { | ||
| 63 | baseTarget: "#09090b", | ||
| 64 | baseWeight: 0.8, | ||
| 65 | accent: { | ||
| 66 | center: [0.28, 0.78], | ||
| 67 | middleOffset: 0.28, | ||
| 68 | middleOpacity: 0.62, | ||
| 69 | endOffset: 0.58, | ||
| 70 | }, | ||
| 71 | action: { | ||
| 72 | center: [0.82, 0.18], | ||
| 73 | startOpacity: 0.45, | ||
| 74 | endOffset: 0.55, | ||
| 75 | }, | ||
| 76 | scale: 1.1, | ||
| 77 | blurAt56Px: 3, | ||
| 78 | }, | ||
| 79 | }; | ||
| 80 | |||
| 81 | type Oklab = Readonly<{ l: number; a: number; b: number }>; | ||
| 82 | |||
| 83 | const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)/; | ||
| 84 | const HEX_PATTERN = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; | ||
| 85 | |||
| 86 | function srgbToLinear(value: number): number { | ||
| 87 | return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; | ||
| 88 | } | ||
| 89 | |||
| 90 | function linearToSrgb(value: number): number { | ||
| 91 | const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055; | ||
| 92 | return Math.round(Math.min(1, Math.max(0, converted)) * 255); | ||
| 93 | } | ||
| 94 | |||
| 95 | function parseOklab(value: string): Oklab | null { | ||
| 96 | const oklch = OKLCH_PATTERN.exec(value); | ||
| 97 | if (oklch) { | ||
| 98 | const lightness = Number(oklch[1]); | ||
| 99 | const chroma = Number(oklch[2]); | ||
| 100 | const hue = (Number(oklch[3]) * Math.PI) / 180; | ||
| 101 | return { l: lightness, a: chroma * Math.cos(hue), b: chroma * Math.sin(hue) }; | ||
| 102 | } | ||
| 103 | |||
| 104 | const hex = HEX_PATTERN.exec(value); | ||
| 105 | if (!hex) return null; | ||
| 106 | const red = srgbToLinear(Number.parseInt(hex[1]!, 16) / 255); | ||
| 107 | const green = srgbToLinear(Number.parseInt(hex[2]!, 16) / 255); | ||
| 108 | const blue = srgbToLinear(Number.parseInt(hex[3]!, 16) / 255); | ||
| 109 | const lRoot = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue); | ||
| 110 | const mRoot = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue); | ||
| 111 | const sRoot = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue); | ||
| 112 | return { | ||
| 113 | l: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot, | ||
| 114 | a: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot, | ||
| 115 | b: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot, | ||
| 116 | }; | ||
| 117 | } | ||
| 118 | |||
| 119 | function oklabToHex(color: Oklab): string { | ||
| 120 | const lPrime = color.l + 0.3963377774 * color.a + 0.2158037573 * color.b; | ||
| 121 | const mPrime = color.l - 0.1055613458 * color.a - 0.0638541728 * color.b; | ||
| 122 | const sPrime = color.l - 0.0894841775 * color.a - 1.291485548 * color.b; | ||
| 123 | const l = lPrime ** 3; | ||
| 124 | const m = mPrime ** 3; | ||
| 125 | const s = sPrime ** 3; | ||
| 126 | const channels = [ | ||
| 127 | linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), | ||
| 128 | linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), | ||
| 129 | linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), | ||
| 130 | ]; | ||
| 131 | return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; | ||
| 132 | } | ||
| 133 | |||
| 134 | export function mixThemePreviewBase(colors: ThemePreviewColors, mode: ThemeAppearance): string { | ||
| 135 | const spec = THEME_PREVIEW_RENDER_SPECS[mode]!; | ||
| 136 | const canvas = parseOklab(colors.canvas); | ||
| 137 | const target = parseOklab(spec.baseTarget); | ||
| 138 | if (!canvas || !target) return colors.canvas; | ||
| 139 | const targetWeight = 1 - spec.baseWeight; | ||
| 140 | return oklabToHex({ | ||
| 141 | l: canvas.l * spec.baseWeight + target.l * targetWeight, | ||
| 142 | a: canvas.a * spec.baseWeight + target.a * targetWeight, | ||
| 143 | b: canvas.b * spec.baseWeight + target.b * targetWeight, | ||
| 144 | }); | ||
| 145 | } | ||
scripts/mobile-showcase.config.ts+21| ... | @@ -1,3 +1,9 @@ | ... | @@ -1,3 +1,9 @@ |
| 1 | import { | ||
| 2 | MOBILE_DEFAULT_THEME_ID, | ||
| 3 | MOBILE_THEME_IDS, | ||
| 4 | type MobileThemeId, | ||
| 5 | } from "@t3tools/shared/themePalettes"; | ||
| 6 | |||
| 1 | import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts"; | 7 | import { SHOWCASE_SCENES, type ShowcaseScene } from "./mobile-showcase-environment.ts"; |
| 2 | 8 | ||
| 3 | export { SHOWCASE_SCENES }; | 9 | export { SHOWCASE_SCENES }; |
| ... | @@ -5,6 +11,11 @@ export type { ShowcaseScene }; | ... | @@ -5,6 +11,11 @@ export type { ShowcaseScene }; |
| 5 | 11 | ||
| 6 | export type ShowcaseAppearance = "light" | "dark"; | 12 | export type ShowcaseAppearance = "light" | "dark"; |
| 7 | 13 | ||
| 14 | /** Every palette the mobile appearance settings can select. */ | ||
| 15 | export const SHOWCASE_THEMES = MOBILE_THEME_IDS; | ||
| 16 | export const DEFAULT_SHOWCASE_THEME = MOBILE_DEFAULT_THEME_ID; | ||
| 17 | export type ShowcaseTheme = MobileThemeId; | ||
| 18 | |||
| 8 | export interface ShowcaseStoreAssetSpec { | 19 | export interface ShowcaseStoreAssetSpec { |
| 9 | readonly store: "apple" | "google-play"; | 20 | readonly store: "apple" | "google-play"; |
| 10 | /** Device directory relative to ShowcaseConfig.outputDirectory. */ | 21 | /** Device directory relative to ShowcaseConfig.outputDirectory. */ |
| ... | @@ -25,6 +36,8 @@ export interface ShowcaseIosDevice { | ... | @@ -25,6 +36,8 @@ export interface ShowcaseIosDevice { |
| 25 | readonly simulatorDeviceType?: string; | 36 | readonly simulatorDeviceType?: string; |
| 26 | /** Appearance used when the CLI does not pass --appearance. */ | 37 | /** Appearance used when the CLI does not pass --appearance. */ |
| 27 | readonly appearance: ShowcaseAppearance; | 38 | readonly appearance: ShowcaseAppearance; |
| 39 | /** Palette used when the CLI does not pass --theme. */ | ||
| 40 | readonly theme: ShowcaseTheme; | ||
| 28 | /** Orientation applied by the capture harness. Defaults to portrait. */ | 41 | /** Orientation applied by the capture harness. Defaults to portrait. */ |
| 29 | readonly orientation?: "portrait" | "landscape"; | 42 | readonly orientation?: "portrait" | "landscape"; |
| 30 | readonly scenes: ReadonlyArray<ShowcaseScene>; | 43 | readonly scenes: ReadonlyArray<ShowcaseScene>; |
| ... | @@ -38,6 +51,8 @@ export interface ShowcaseAndroidDevice { | ... | @@ -38,6 +51,8 @@ export interface ShowcaseAndroidDevice { |
| 38 | readonly avd: string; | 51 | readonly avd: string; |
| 39 | /** Appearance used when the CLI does not pass --appearance. */ | 52 | /** Appearance used when the CLI does not pass --appearance. */ |
| 40 | readonly appearance: ShowcaseAppearance; | 53 | readonly appearance: ShowcaseAppearance; |
| 54 | /** Palette used when the CLI does not pass --theme. */ | ||
| 55 | readonly theme: ShowcaseTheme; | ||
| 41 | /** Native ABI used by the AVD, from its config.ini `abi.type`. */ | 56 | /** Native ABI used by the AVD, from its config.ini `abi.type`. */ |
| 42 | readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a"; | 57 | readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a"; |
| 43 | readonly scenes: ReadonlyArray<ShowcaseScene>; | 58 | readonly scenes: ReadonlyArray<ShowcaseScene>; |
| ... | @@ -92,6 +107,7 @@ const config: ShowcaseConfig = { | ... | @@ -92,6 +107,7 @@ const config: ShowcaseConfig = { |
| 92 | simulator: "iPhone 17 Pro Max", | 107 | simulator: "iPhone 17 Pro Max", |
| 93 | simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max", | 108 | simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max", |
| 94 | appearance: "dark", | 109 | appearance: "dark", |
| 110 | theme: DEFAULT_SHOWCASE_THEME, | ||
| 95 | scenes: ["thread", "terminal", "review", "threads", "environments"], | 111 | scenes: ["thread", "terminal", "review", "threads", "environments"], |
| 96 | storeAsset: { | 112 | storeAsset: { |
| 97 | store: "apple", | 113 | store: "apple", |
| ... | @@ -108,6 +124,7 @@ const config: ShowcaseConfig = { | ... | @@ -108,6 +124,7 @@ const config: ShowcaseConfig = { |
| 108 | simulator: "T3 Showcase iPhone 14 Plus", | 124 | simulator: "T3 Showcase iPhone 14 Plus", |
| 109 | simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus", | 125 | simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus", |
| 110 | appearance: "dark", | 126 | appearance: "dark", |
| 127 | theme: DEFAULT_SHOWCASE_THEME, | ||
| 111 | scenes: ["thread", "terminal", "review", "threads", "environments"], | 128 | scenes: ["thread", "terminal", "review", "threads", "environments"], |
| 112 | storeAsset: { | 129 | storeAsset: { |
| 113 | store: "apple", | 130 | store: "apple", |
| ... | @@ -124,6 +141,7 @@ const config: ShowcaseConfig = { | ... | @@ -124,6 +141,7 @@ const config: ShowcaseConfig = { |
| 124 | simulator: "iPad Pro 13-inch (M5)", | 141 | simulator: "iPad Pro 13-inch (M5)", |
| 125 | simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB", | 142 | simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB", |
| 126 | appearance: "dark", | 143 | appearance: "dark", |
| 144 | theme: DEFAULT_SHOWCASE_THEME, | ||
| 127 | orientation: "landscape", | 145 | orientation: "landscape", |
| 128 | scenes: ["thread", "terminal", "review", "threads", "environments"], | 146 | scenes: ["thread", "terminal", "review", "threads", "environments"], |
| 129 | storeAsset: { | 147 | storeAsset: { |
| ... | @@ -143,6 +161,7 @@ const config: ShowcaseConfig = { | ... | @@ -143,6 +161,7 @@ const config: ShowcaseConfig = { |
| 143 | // Blacksmith Linux runner can use KVM acceleration. | 161 | // Blacksmith Linux runner can use KVM acceleration. |
| 144 | abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), | 162 | abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), |
| 145 | appearance: "dark", | 163 | appearance: "dark", |
| 164 | theme: DEFAULT_SHOWCASE_THEME, | ||
| 146 | viewport: { | 165 | viewport: { |
| 147 | width: 1080, | 166 | width: 1080, |
| 148 | height: 1920, | 167 | height: 1920, |
| ... | @@ -165,6 +184,7 @@ const config: ShowcaseConfig = { | ... | @@ -165,6 +184,7 @@ const config: ShowcaseConfig = { |
| 165 | avd: "Pixel_10_Pro", | 184 | avd: "Pixel_10_Pro", |
| 166 | abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), | 185 | abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), |
| 167 | appearance: "dark", | 186 | appearance: "dark", |
| 187 | theme: DEFAULT_SHOWCASE_THEME, | ||
| 168 | viewport: { | 188 | viewport: { |
| 169 | width: 1080, | 189 | width: 1080, |
| 170 | height: 1920, | 190 | height: 1920, |
| ... | @@ -187,6 +207,7 @@ const config: ShowcaseConfig = { | ... | @@ -187,6 +207,7 @@ const config: ShowcaseConfig = { |
| 187 | avd: "Pixel_10_Pro", | 207 | avd: "Pixel_10_Pro", |
| 188 | abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), | 208 | abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), |
| 189 | appearance: "dark", | 209 | appearance: "dark", |
| 210 | theme: DEFAULT_SHOWCASE_THEME, | ||
| 190 | viewport: { | 211 | viewport: { |
| 191 | width: 1440, | 212 | width: 1440, |
| 192 | height: 2560, | 213 | height: 2560, |
scripts/mobile-showcase.test.ts+58-2| ... | @@ -2,7 +2,9 @@ import { assert, it } from "@effect/vitest"; | ... | @@ -2,7 +2,9 @@ import { assert, it } from "@effect/vitest"; |
| 2 | import { PNG } from "pngjs"; | 2 | import { PNG } from "pngjs"; |
| 3 | 3 | ||
| 4 | import showcaseConfig, { | 4 | import showcaseConfig, { |
| 5 | DEFAULT_SHOWCASE_THEME, | ||
| 5 | resolveShowcaseAndroidAbi, | 6 | resolveShowcaseAndroidAbi, |
| 7 | SHOWCASE_THEMES, | ||
| 6 | type ShowcaseConfig, | 8 | type ShowcaseConfig, |
| 7 | type ShowcaseStoreAssetSpec, | 9 | type ShowcaseStoreAssetSpec, |
| 8 | } from "./mobile-showcase.config.ts"; | 10 | } from "./mobile-showcase.config.ts"; |
| ... | @@ -55,6 +57,7 @@ const config: ShowcaseConfig = { | ... | @@ -55,6 +57,7 @@ const config: ShowcaseConfig = { |
| 55 | platform: "ios", | 57 | platform: "ios", |
| 56 | simulator: "iPhone Test", | 58 | simulator: "iPhone Test", |
| 57 | appearance: "dark", | 59 | appearance: "dark", |
| 60 | theme: "t3-code", | ||
| 58 | scenes: ["thread", "review"], | 61 | scenes: ["thread", "review"], |
| 59 | storeAsset: appleSpec, | 62 | storeAsset: appleSpec, |
| 60 | }, | 63 | }, |
| ... | @@ -63,6 +66,7 @@ const config: ShowcaseConfig = { | ... | @@ -63,6 +66,7 @@ const config: ShowcaseConfig = { |
| 63 | platform: "android", | 66 | platform: "android", |
| 64 | avd: "Pixel_Test", | 67 | avd: "Pixel_Test", |
| 65 | appearance: "light", | 68 | appearance: "light", |
| 69 | theme: "t3-code", | ||
| 66 | scenes: ["thread", "terminal"], | 70 | scenes: ["thread", "terminal"], |
| 67 | storeAsset: googleSpec, | 71 | storeAsset: googleSpec, |
| 68 | }, | 72 | }, |
| ... | @@ -95,6 +99,23 @@ it("rejects unsupported system appearances", () => { | ... | @@ -95,6 +99,23 @@ it("rejects unsupported system appearances", () => { |
| 95 | ); | 99 | ); |
| 96 | }); | 100 | }); |
| 97 | 101 | ||
| 102 | it("parses repeatable and expanded theme filters", () => { | ||
| 103 | assert.deepStrictEqual( | ||
| 104 | [...parseShowcaseCliArgs(["--theme", "ocean", "--theme", "ember"]).themes], | ||
| 105 | ["ocean", "ember"], | ||
| 106 | ); | ||
| 107 | assert.deepStrictEqual( | ||
| 108 | [...parseShowcaseCliArgs(["--theme", "all"]).themes], | ||
| 109 | [...SHOWCASE_THEMES], | ||
| 110 | ); | ||
| 111 | }); | ||
| 112 | |||
| 113 | // The app normalizes an unknown id back to its default palette, so a typo here | ||
| 114 | // would otherwise produce screenshots labeled with a theme they do not show. | ||
| 115 | it("rejects unsupported themes instead of capturing the default palette", () => { | ||
| 116 | assert.throws(() => parseShowcaseCliArgs(["--theme", "sunset"]), /Unsupported theme 'sunset'/u); | ||
| 117 | }); | ||
| 118 | |||
| 98 | it("parses validation-only mode", () => { | 119 | it("parses validation-only mode", () => { |
| 99 | assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true); | 120 | assert.equal(parseShowcaseCliArgs(["--validate-only"]).validateOnly, true); |
| 100 | }); | 121 | }); |
| ... | @@ -146,8 +167,35 @@ it("expands both appearances into independent upload-ready directories", () => { | ... | @@ -146,8 +167,35 @@ it("expands both appearances into independent upload-ready directories", () => { |
| 146 | directory: showcaseCaptureDirectory("/captures", capture), | 167 | directory: showcaseCaptureDirectory("/captures", capture), |
| 147 | })), | 168 | })), |
| 148 | [ | 169 | [ |
| 149 | { appearance: "light", directory: "/captures/apple/iphone-test/light" }, | 170 | { appearance: "light", directory: "/captures/apple/iphone-test/light/t3-code" }, |
| 150 | { appearance: "dark", directory: "/captures/apple/iphone-test/dark" }, | 171 | { appearance: "dark", directory: "/captures/apple/iphone-test/dark/t3-code" }, |
| 172 | ], | ||
| 173 | ); | ||
| 174 | }); | ||
| 175 | |||
| 176 | // Every palette needs its own leaf folder: one directory holding several themes | ||
| 177 | // would mix upload slots and break the per-store screenshot count limits. | ||
| 178 | it("expands themes into independent upload-ready directories per appearance", () => { | ||
| 179 | const options = parseShowcaseCliArgs([ | ||
| 180 | "--device", | ||
| 181 | "phone", | ||
| 182 | "--appearance", | ||
| 183 | "both", | ||
| 184 | "--theme", | ||
| 185 | "ocean", | ||
| 186 | "--theme", | ||
| 187 | "ember", | ||
| 188 | ]); | ||
| 189 | |||
| 190 | assert.deepStrictEqual( | ||
| 191 | planShowcaseCaptures(config, options).map((capture) => | ||
| 192 | showcaseCaptureDirectory("/captures", capture), | ||
| 193 | ), | ||
| 194 | [ | ||
| 195 | "/captures/apple/iphone-test/light/ocean", | ||
| 196 | "/captures/apple/iphone-test/light/ember", | ||
| 197 | "/captures/apple/iphone-test/dark/ocean", | ||
| 198 | "/captures/apple/iphone-test/dark/ember", | ||
| 151 | ], | 199 | ], |
| 152 | ); | 200 | ); |
| 153 | }); | 201 | }); |
| ... | @@ -211,6 +259,14 @@ it("enforces store screenshot count limits", () => { | ... | @@ -211,6 +259,14 @@ it("enforces store screenshot count limits", () => { |
| 211 | assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u); | 259 | assert.throws(() => validateStoreAssetCount(googleSpec, 9, false), /allows at most 8/u); |
| 212 | }); | 260 | }); |
| 213 | 261 | ||
| 262 | it("defaults every device to the app's own palette", () => { | ||
| 263 | assert.equal(DEFAULT_SHOWCASE_THEME, "t3-code"); | ||
| 264 | assert.equal( | ||
| 265 | showcaseConfig.devices.every((device) => device.theme === DEFAULT_SHOWCASE_THEME), | ||
| 266 | true, | ||
| 267 | ); | ||
| 268 | }); | ||
| 269 | |||
| 214 | it("configures every default device with an exact upload-ready store target", () => { | 270 | it("configures every default device with an exact upload-ready store target", () => { |
| 215 | assert.deepStrictEqual( | 271 | assert.deepStrictEqual( |
| 216 | showcaseConfig.devices.map((device) => [ | 272 | showcaseConfig.devices.map((device) => [ |
scripts/mobile-showcase.ts+48-12| ... | @@ -20,6 +20,8 @@ import showcaseConfig, { | ... | @@ -20,6 +20,8 @@ import showcaseConfig, { |
| 20 | type ShowcaseStoreAssetSpec, | 20 | type ShowcaseStoreAssetSpec, |
| 21 | SHOWCASE_SCENES, | 21 | SHOWCASE_SCENES, |
| 22 | type ShowcaseScene, | 22 | type ShowcaseScene, |
| 23 | SHOWCASE_THEMES, | ||
| 24 | type ShowcaseTheme, | ||
| 23 | } from "./mobile-showcase.config.ts"; | 25 | } from "./mobile-showcase.config.ts"; |
| 24 | import { | 26 | import { |
| 25 | SHOWCASE_ENVIRONMENTS, | 27 | SHOWCASE_ENVIRONMENTS, |
| ... | @@ -76,6 +78,7 @@ interface CliOptions { | ... | @@ -76,6 +78,7 @@ interface CliOptions { |
| 76 | readonly deviceIds: ReadonlySet<string>; | 78 | readonly deviceIds: ReadonlySet<string>; |
| 77 | readonly scenes: ReadonlySet<ShowcaseScene>; | 79 | readonly scenes: ReadonlySet<ShowcaseScene>; |
| 78 | readonly appearances: ReadonlySet<ShowcaseAppearance>; | 80 | readonly appearances: ReadonlySet<ShowcaseAppearance>; |
| 81 | readonly themes: ReadonlySet<ShowcaseTheme>; | ||
| 79 | readonly skipBuild: boolean; | 82 | readonly skipBuild: boolean; |
| 80 | readonly skipMetro: boolean; | 83 | readonly skipMetro: boolean; |
| 81 | readonly keepRunning: boolean; | 84 | readonly keepRunning: boolean; |
| ... | @@ -87,6 +90,7 @@ export interface ShowcaseCapture { | ... | @@ -87,6 +90,7 @@ export interface ShowcaseCapture { |
| 87 | readonly device: ShowcaseDevice; | 90 | readonly device: ShowcaseDevice; |
| 88 | readonly scenes: ReadonlyArray<ShowcaseScene>; | 91 | readonly scenes: ReadonlyArray<ShowcaseScene>; |
| 89 | readonly appearance: ShowcaseAppearance; | 92 | readonly appearance: ShowcaseAppearance; |
| 93 | readonly theme: ShowcaseTheme; | ||
| 90 | } | 94 | } |
| 91 | 95 | ||
| 92 | interface IosCaptureCleanup { | 96 | interface IosCaptureCleanup { |
| ... | @@ -223,9 +227,16 @@ export function validateStoreAssetCount( | ... | @@ -223,9 +227,16 @@ export function validateStoreAssetCount( |
| 223 | 227 | ||
| 224 | export function showcaseCaptureDirectory( | 228 | export function showcaseCaptureDirectory( |
| 225 | outputDirectory: string, | 229 | outputDirectory: string, |
| 226 | capture: Pick<ShowcaseCapture, "device" | "appearance">, | 230 | capture: Pick<ShowcaseCapture, "device" | "appearance" | "theme">, |
| 227 | ): string { | 231 | ): string { |
| 228 | return NodePath.join(outputDirectory, capture.device.storeAsset.directory, capture.appearance); | 232 | // Each palette owns a leaf folder so one upload slot never mixes themes and |
| 233 | // every folder keeps a store-legal screenshot count of its own. | ||
| 234 | return NodePath.join( | ||
| 235 | outputDirectory, | ||
| 236 | capture.device.storeAsset.directory, | ||
| 237 | capture.appearance, | ||
| 238 | capture.theme, | ||
| 239 | ); | ||
| 229 | } | 240 | } |
| 230 | 241 | ||
| 231 | async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise<void> { | 242 | async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise<void> { |
| ... | @@ -276,6 +287,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions { | ... | @@ -276,6 +287,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions { |
| 276 | const deviceIds = new Set<string>(); | 287 | const deviceIds = new Set<string>(); |
| 277 | const scenes = new Set<ShowcaseScene>(); | 288 | const scenes = new Set<ShowcaseScene>(); |
| 278 | const appearances = new Set<ShowcaseAppearance>(); | 289 | const appearances = new Set<ShowcaseAppearance>(); |
| 290 | const themes = new Set<ShowcaseTheme>(); | ||
| 279 | let skipBuild = false; | 291 | let skipBuild = false; |
| 280 | let skipMetro = false; | 292 | let skipMetro = false; |
| 281 | let keepRunning = false; | 293 | let keepRunning = false; |
| ... | @@ -318,6 +330,18 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions { | ... | @@ -318,6 +330,18 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions { |
| 318 | appearances.add(value); | 330 | appearances.add(value); |
| 319 | } | 331 | } |
| 320 | index += 1; | 332 | index += 1; |
| 333 | } else if (argument === "--theme") { | ||
| 334 | const value = argumentValue(args, index, argument); | ||
| 335 | if (value === "all") { | ||
| 336 | for (const theme of SHOWCASE_THEMES) themes.add(theme); | ||
| 337 | } else if (SHOWCASE_THEMES.some((theme) => theme === value)) { | ||
| 338 | themes.add(value as ShowcaseTheme); | ||
| 339 | } else { | ||
| 340 | // The app silently falls back to its default palette for an unknown id, | ||
| 341 | // so reject it here rather than shipping a mislabeled screenshot. | ||
| 342 | throw new Error(`Unsupported theme '${value}'. Use ${SHOWCASE_THEMES.join(", ")}, or all.`); | ||
| 343 | } | ||
| 344 | index += 1; | ||
| 321 | } else if (argument === "--skip-build") { | 345 | } else if (argument === "--skip-build") { |
| 322 | skipBuild = true; | 346 | skipBuild = true; |
| 323 | } else if (argument === "--skip-metro") { | 347 | } else if (argument === "--skip-metro") { |
| ... | @@ -340,6 +364,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions { | ... | @@ -340,6 +364,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions { |
| 340 | deviceIds, | 364 | deviceIds, |
| 341 | scenes, | 365 | scenes, |
| 342 | appearances, | 366 | appearances, |
| 367 | themes, | ||
| 343 | skipBuild, | 368 | skipBuild, |
| 344 | skipMetro, | 369 | skipMetro, |
| 345 | keepRunning, | 370 | keepRunning, |
| ... | @@ -350,7 +375,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions { | ... | @@ -350,7 +375,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions { |
| 350 | 375 | ||
| 351 | export function planShowcaseCaptures( | 376 | export function planShowcaseCaptures( |
| 352 | config: ShowcaseConfig, | 377 | config: ShowcaseConfig, |
| 353 | options: Pick<CliOptions, "platforms" | "deviceIds" | "scenes" | "appearances">, | 378 | options: Pick<CliOptions, "platforms" | "deviceIds" | "scenes" | "appearances" | "themes">, |
| 354 | ): ReadonlyArray<ShowcaseCapture> { | 379 | ): ReadonlyArray<ShowcaseCapture> { |
| 355 | const captures = config.devices | 380 | const captures = config.devices |
| 356 | .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform)) | 381 | .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform)) |
| ... | @@ -358,14 +383,18 @@ export function planShowcaseCaptures( | ... | @@ -358,14 +383,18 @@ export function planShowcaseCaptures( |
| 358 | .flatMap((device) => { | 383 | .flatMap((device) => { |
| 359 | const appearances = | 384 | const appearances = |
| 360 | options.appearances.size === 0 ? [device.appearance] : options.appearances; | 385 | options.appearances.size === 0 ? [device.appearance] : options.appearances; |
| 361 | return [...appearances].map((appearance) => ({ | 386 | const themes = options.themes.size === 0 ? [device.theme] : options.themes; |
| 362 | device, | 387 | return [...appearances].flatMap((appearance) => |
| 363 | appearance, | 388 | [...themes].map((theme) => ({ |
| 364 | scenes: | 389 | device, |
| 365 | options.scenes.size === 0 | 390 | appearance, |
| 366 | ? device.scenes | 391 | theme, |
| 367 | : device.scenes.filter((scene) => options.scenes.has(scene)), | 392 | scenes: |
| 368 | })); | 393 | options.scenes.size === 0 |
| 394 | ? device.scenes | ||
| 395 | : device.scenes.filter((scene) => options.scenes.has(scene)), | ||
| 396 | })), | ||
| 397 | ); | ||
| 369 | }) | 398 | }) |
| 370 | .filter((capture) => capture.scenes.length > 0); | 399 | .filter((capture) => capture.scenes.length > 0); |
| 371 | 400 | ||
| ... | @@ -393,6 +422,7 @@ Options: | ... | @@ -393,6 +422,7 @@ Options: |
| 393 | --scene <name> Capture one scene (repeatable) | 422 | --scene <name> Capture one scene (repeatable) |
| 394 | --appearance light|dark|both | 423 | --appearance light|dark|both |
| 395 | Override the configured appearance | 424 | Override the configured appearance |
| 425 | --theme <id>|all Override the configured palette (repeatable) | ||
| 396 | --skip-build Reuse the existing simulator app / debug APK | 426 | --skip-build Reuse the existing simulator app / debug APK |
| 397 | --skip-metro Reuse an already running showcase Metro server | 427 | --skip-metro Reuse an already running showcase Metro server |
| 398 | --keep-running Leave devices and Metro running after capture | 428 | --keep-running Leave devices and Metro running after capture |
| ... | @@ -400,12 +430,13 @@ Options: | ... | @@ -400,12 +430,13 @@ Options: |
| 400 | --list Print this help and the configured matrix | 430 | --list Print this help and the configured matrix |
| 401 | 431 | ||
| 402 | Scenes: ${SHOWCASE_SCENES.join(", ")} | 432 | Scenes: ${SHOWCASE_SCENES.join(", ")} |
| 433 | Themes: ${SHOWCASE_THEMES.join(", ")} | ||
| 403 | 434 | ||
| 404 | Configured devices: | 435 | Configured devices: |
| 405 | ${config.devices | 436 | ${config.devices |
| 406 | .map((device) => { | 437 | .map((device) => { |
| 407 | const target = device.platform === "ios" ? device.simulator : device.avd; | 438 | const target = device.platform === "ios" ? device.simulator : device.avd; |
| 408 | return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark} (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance}) [${device.scenes.join(", ")}]`; | 439 | return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark}/<theme> (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance} ${device.theme}) [${device.scenes.join(", ")}]`; |
| 409 | }) | 440 | }) |
| 410 | .join("\n")} | 441 | .join("\n")} |
| 411 | `); | 442 | `); |
| ... | @@ -946,6 +977,8 @@ async function captureIos( | ... | @@ -946,6 +977,8 @@ async function captureIos( |
| 946 | JSON.stringify(pairingUrls), | 977 | JSON.stringify(pairingUrls), |
| 947 | "--showcaseScene", | 978 | "--showcaseScene", |
| 948 | firstScene, | 979 | firstScene, |
| 980 | "--showcaseTheme", | ||
| 981 | capture.theme, | ||
| 949 | // The app rotates itself; Simulator menu UI scripting needs macOS | 982 | // The app rotates itself; Simulator menu UI scripting needs macOS |
| 950 | // Accessibility permission that CI runners do not grant to osascript. | 983 | // Accessibility permission that CI runners do not grant to osascript. |
| 951 | "--showcaseOrientation", | 984 | "--showcaseOrientation", |
| ... | @@ -1204,6 +1237,9 @@ async function captureAndroid( | ... | @@ -1204,6 +1237,9 @@ async function captureAndroid( |
| 1204 | "--es", | 1237 | "--es", |
| 1205 | "showcaseScene", | 1238 | "showcaseScene", |
| 1206 | firstScene, | 1239 | firstScene, |
| 1240 | "--es", | ||
| 1241 | "showcaseTheme", | ||
| 1242 | capture.theme, | ||
| 1207 | ANDROID_PACKAGE, | 1243 | ANDROID_PACKAGE, |
| 1208 | ]); | 1244 | ]); |
| 1209 | for (const [sceneIndex, scene] of capture.scenes.entries()) { | 1245 | for (const [sceneIndex, scene] of capture.scenes.entries()) { |