The app combines a user appearance override, React Navigation theme, and semantic colors for screens and components.
Settings → Appearance offers:
- System — follow the device:
Appearance.setColorScheme('unspecified'). (Do not passnullon Android —AppearanceModule.setColorSchemerequires a non-null style and will crash.) - Light / Dark — lock the app:
Appearance.setColorScheme('light' | 'dark')
Implementation:
src/ctx/theme-preference-context.tsx—ThemePreferenceProvider(wraps near root insrc/app/_layout.tsx, outsideSessionProviderso sign-in respects the choice)- Preference is applied in
useLayoutEffect(before paint) to reduce header/body mismatch on first frame. - Preference is stored under
app_theme_preference_v1viauseStorageState(SecureStore /localStorage) useThemePreference()exposespreference,setPreference,resolvedColorScheme(light|dark),isPreferenceReady
Resolved scheme comes from resolveColorSchemeFromPreference() in src/constants/theme-preference.ts:
- If the user chose Light or Dark, that wins (stays aligned with
Appearance.setColorSchemeeven ifuseColorScheme()is briefly out of sync). - If System, uses
useColorScheme()from the hook, then falls back toAppearance.getColorScheme()when the hook reportsunspecified/ null-ish.
React Navigation’s ThemeProvider and expo-status-bar StatusBar use resolvedColorScheme from the same context.
src/hooks/use-native-theme-colors.ts defines semantic colors:
background,surface,text,secondaryTextprimary,border,error,success,placeholder
iOS — Expo Router Color.ios.* (system dynamic colors follow Appearance).
Android — materialAndroidUiColors(isDark) from src/utils/navigation-theme.ts, keyed off resolvedColorScheme. The app does not use Color.android.dynamic.* for this chrome: those tokens track device night mode and ignore Appearance.setColorScheme, which made Light/Dark in Settings look inverted.
Web — hex fallbacks keyed off resolvedColorScheme.
Components should re-render when appearance changes — useNativeThemeColors() depends on useThemePreference().resolvedColorScheme and still calls useColorScheme() so System mode updates when the OS toggles.
src/app/_layout.tsx passes createAppNavigationTheme(resolvedColorScheme) into ThemeProvider and uses functional screenOptions={({ theme }) => ({ … })} so native stack headers use the same theme.colors as the rest of the app. See project-structure.md.
Signed-in Chat and Settings tabs use headerShown: false on the drawer/tabs shell; their top bars are TabScreenHeader (src/components/tab-screen-header.tsx), themed with useNativeThemeColors() (background, hairline border, title and icon tint) so they match stack-auth screens and the chat body.
src/components/markdown-message.tsx:
tone="default"— assistant (and general) markdown on neutral surfacestone="onPrimary"— user messages on the primary-colored bubble (light text, adjusted code/link styles)
See src/components/markdown-message.tsx.
ThemedView / ThemedText (under src/components/) use src/hooks/use-theme.ts, which keys off useColorScheme() — they follow the same appearance override once the provider has applied it.