| author | |
| committer | |
| log | 579266caa89238611ddb974ffebe9968c6a6e5d5 |
| tree | 36bb21155962260c99071defad07a8fcf4e76d25 |
| parent | 47eed9face4186635488fba5f2494eddd13f6491 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Pixel Perfect <me@pixp.cc>19 files changed, 374 insertions(+), 25 deletions(-)
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt+40-1| ... | ... | @@ -7,11 +7,13 @@ import android.graphics.Canvas |
| 7 | 7 | import android.graphics.Paint |
| 8 | 8 | import android.graphics.RectF |
| 9 | 9 | import android.graphics.Typeface |
| 10 | import android.os.Build | |
| 10 | 11 | import android.text.Editable |
| 11 | 12 | import android.text.InputType |
| 12 | 13 | import android.text.Spanned |
| 13 | 14 | import android.text.TextWatcher |
| 14 | 15 | import android.text.style.ReplacementSpan |
| 16 | import android.util.TypedValue | |
| 15 | 17 | import android.view.Gravity |
| 16 | 18 | import android.view.ViewGroup |
| 17 | 19 | import android.view.inputmethod.InputMethodManager |
| ... | ... | @@ -27,6 +29,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 27 | 29 | appContext |
| 28 | 30 | ) { |
| 29 | 31 | private val editor = SelectionAwareEditText(context) |
| 32 | private val defaultHighlightColor = editor.highlightColor | |
| 33 | private val defaultSelectionColor = context.resolveThemeColor( | |
| 34 | android.R.attr.colorAccent, | |
| 35 | editor.currentTextColor, | |
| 36 | ) | |
| 30 | 37 | private val onComposerChange by EventDispatcher() |
| 31 | 38 | private val onComposerSelectionChange by EventDispatcher() |
| 32 | 39 | private val onComposerFocus by EventDispatcher() |
| ... | ... | @@ -152,6 +159,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 152 | 159 | val theme = JSONObject(themeJson) |
| 153 | 160 | editor.setTextColor(parseColor(theme.optString("text"), Color.BLACK)) |
| 154 | 161 | editor.setHintTextColor(parseColor(theme.optString("placeholder"), Color.GRAY)) |
| 162 | if (theme.isNull("selection")) { | |
| 163 | resetSelectionTheme() | |
| 164 | } else { | |
| 165 | applySelectionTheme(parseColor(theme.optString("selection"), editor.currentTextColor)) | |
| 166 | } | |
| 155 | 167 | chipTheme = ComposerChipTheme( |
| 156 | 168 | chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground), |
| 157 | 169 | chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder), |
| ... | ... | @@ -277,6 +289,21 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 277 | 289 | editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f) |
| 278 | 290 | } |
| 279 | 291 | |
| 292 | private fun applySelectionTheme(color: Int) { | |
| 293 | editor.highlightColor = color.withAlpha(0x52) | |
| 294 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return | |
| 295 | ||
| 296 | editor.textCursorDrawable = editor.textCursorDrawable?.mutate()?.apply { setTint(color) } | |
| 297 | editor.textSelectHandle?.mutate()?.setTint(color) | |
| 298 | editor.textSelectHandleLeft?.mutate()?.setTint(color) | |
| 299 | editor.textSelectHandleRight?.mutate()?.setTint(color) | |
| 300 | } | |
| 301 | ||
| 302 | private fun resetSelectionTheme() { | |
| 303 | applySelectionTheme(defaultSelectionColor) | |
| 304 | editor.highlightColor = defaultHighlightColor | |
| 305 | } | |
| 306 | ||
| 280 | 307 | private fun currentSelectionPayload(): Map<String, Int> = |
| 281 | 308 | mapOf( |
| 282 | 309 | "start" to editor.selectionStart.coerceAtLeast(0), |
| ... | ... | @@ -335,12 +362,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( |
| 335 | 362 | |
| 336 | 363 | private fun parseColor(value: String, fallback: Int): Int = |
| 337 | 364 | try { |
| 338 | Color.parseColor(value) | |
| 365 | val androidColor = if (value.length == 9 && value.startsWith("#")) { | |
| 366 | "#${value.takeLast(2)}${value.substring(1, 7)}" | |
| 367 | } else { | |
| 368 | value | |
| 369 | } | |
| 370 | Color.parseColor(androidColor) | |
| 339 | 371 | } catch (_: Exception) { |
| 340 | 372 | fallback |
| 341 | 373 | } |
| 342 | 374 | } |
| 343 | 375 | |
| 376 | private fun Int.withAlpha(alpha: Int): Int = (this and 0x00FFFFFF) or (alpha shl 24) | |
| 377 | ||
| 378 | private fun Context.resolveThemeColor(attribute: Int, fallback: Int): Int { | |
| 379 | val value = TypedValue() | |
| 380 | return if (theme.resolveAttribute(attribute, value, true)) value.data else fallback | |
| 381 | } | |
| 382 | ||
| 344 | 383 | private data class ComposerToken( |
| 345 | 384 | val type: String, |
| 346 | 385 | val source: String, |
apps/mobile/package.json+1| ... | ... | @@ -51,6 +51,7 @@ |
| 51 | 51 | "@expo/metro-runtime": "~57.0.14", |
| 52 | 52 | "@expo/ui": "~57.0.14", |
| 53 | 53 | "@legendapp/list": "catalog:", |
| 54 | "@material/material-color-utilities": "0.3.0", | |
| 54 | 55 | "@noble/curves": "catalog:", |
| 55 | 56 | "@pierre/diffs": "catalog:", |
| 56 | 57 | "@react-native-ai/apple": "0.12.0", |
apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+1-1| ... | ... | @@ -66,7 +66,7 @@ function filesPatch(paths: ReadonlyArray<string>) { |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) { |
| 69 | return themeId === DEFAULT_MOBILE_THEME_ID | |
| 69 | return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" | |
| 70 | 70 | ? readDefaultMobileThemeVariables(appearance) |
| 71 | 71 | : getMobileThemeVariables(themeId, appearance); |
| 72 | 72 | } |
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+60-3| ... | ... | @@ -3,17 +3,19 @@ import { |
| 3 | 3 | startTransition, |
| 4 | 4 | use, |
| 5 | 5 | useCallback, |
| 6 | useEffect, | |
| 7 | useState, | |
| 6 | 8 | useLayoutEffect, |
| 7 | 9 | useMemo, |
| 8 | 10 | useRef, |
| 9 | 11 | type ReactNode, |
| 10 | 12 | } from "react"; |
| 11 | import { Appearance, useColorScheme } from "react-native"; | |
| 13 | import { AppState, Appearance, useColorScheme } from "react-native"; | |
| 12 | 14 | |
| 13 | 15 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 14 | 16 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 15 | 17 | |
| 16 | import { ScopedTheme, Uniwind } from "uniwind"; | |
| 18 | import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind"; | |
| 17 | 19 | |
| 18 | 20 | import { |
| 19 | 21 | resolveAppearance, |
| ... | ... | @@ -22,6 +24,10 @@ import { |
| 22 | 24 | } from "../../../lib/appearancePreferences"; |
| 23 | 25 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; |
| 24 | 26 | import type { Preferences } from "../../../persistence/mobile-preferences"; |
| 27 | import { isSystemColorsAvailable, readSystemColorPalettes } from "../../../lib/materialYouPalette"; | |
| 28 | import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme"; | |
| 29 | import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables"; | |
| 30 | import type { MobileThemeVariables } from "../../../lib/mobileTheme"; | |
| 25 | 31 | import { |
| 26 | 32 | createMobileThemePairPatch, |
| 27 | 33 | createMobileThemeSelectionPatch, |
| ... | ... | @@ -45,6 +51,13 @@ interface AppearancePreferencesContextValue { |
| 45 | 51 | readonly themeIds: MobileThemeIds; |
| 46 | 52 | readonly themeMode: MobileThemeMode; |
| 47 | 53 | readonly themeAppearance: MobileThemeAppearance; |
| 54 | readonly systemColorsAvailable: boolean; | |
| 55 | readonly systemColorsActive: boolean; | |
| 56 | readonly themeVariables: MobileThemeVariables; | |
| 57 | readonly themeVariablesByAppearance: Readonly< | |
| 58 | Record<MobileThemeAppearance, MobileThemeVariables> | |
| 59 | >; | |
| 60 | readonly systemColorPalettes: ReturnType<typeof readSystemColorPalettes>; | |
| 48 | 61 | readonly isReady: boolean; |
| 49 | 62 | readonly setThemeIdForAppearance: ( |
| 50 | 63 | appearance: MobileThemeAppearance, |
| ... | ... | @@ -81,6 +94,39 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 81 | 94 | [resolvedThemeIds.dark, resolvedThemeIds.light], |
| 82 | 95 | ); |
| 83 | 96 | const themeId = themeIds[themeAppearance]; |
| 97 | const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; | |
| 98 | const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); | |
| 99 | useEffect(() => { | |
| 100 | if (!isSystemColorsAvailable) return; | |
| 101 | const refresh = () => { | |
| 102 | const next = readSystemColorPalettes(); | |
| 103 | setSystemColorPalettes((previous) => | |
| 104 | JSON.stringify(previous) === JSON.stringify(next) ? previous : next, | |
| 105 | ); | |
| 106 | }; | |
| 107 | const subscription = AppState.addEventListener("change", (state) => { | |
| 108 | if (state === "active") refresh(); | |
| 109 | }); | |
| 110 | const focusSubscription = AppState.addEventListener("focus", refresh); | |
| 111 | return () => { | |
| 112 | subscription.remove(); | |
| 113 | focusSubscription.remove(); | |
| 114 | }; | |
| 115 | }, []); | |
| 116 | const themeVariablesByAppearance = useMemo(() => { | |
| 117 | const resolve = (appearance: MobileThemeAppearance) => { | |
| 118 | const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance); | |
| 119 | return themeIds[appearance] === "material-you" && systemColorPalettes | |
| 120 | ? materialYouPaletteToMobileThemeVariables( | |
| 121 | systemColorPalettes[appearance], | |
| 122 | appearance, | |
| 123 | base, | |
| 124 | ) | |
| 125 | : base; | |
| 126 | }; | |
| 127 | return { light: resolve("light"), dark: resolve("dark") }; | |
| 128 | }, [themeIds, systemColorPalettes]); | |
| 129 | const themeVariables = themeVariablesByAppearance[themeAppearance]; | |
| 84 | 130 | const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); |
| 85 | 131 | const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; |
| 86 | 132 | const appearance = useMemo( |
| ... | ... | @@ -233,6 +279,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 233 | 279 | themeIds, |
| 234 | 280 | themeMode, |
| 235 | 281 | themeAppearance, |
| 282 | systemColorsAvailable: isSystemColorsAvailable, | |
| 283 | systemColorsActive, | |
| 284 | themeVariables, | |
| 285 | themeVariablesByAppearance, | |
| 286 | systemColorPalettes, | |
| 236 | 287 | isReady, |
| 237 | 288 | setThemeIdForAppearance, |
| 238 | 289 | setThemeIdForBothAppearances, |
| ... | ... | @@ -248,6 +299,10 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 248 | 299 | themeIds, |
| 249 | 300 | themeMode, |
| 250 | 301 | themeAppearance, |
| 302 | systemColorsActive, | |
| 303 | themeVariables, | |
| 304 | themeVariablesByAppearance, | |
| 305 | systemColorPalettes, | |
| 251 | 306 | isReady, |
| 252 | 307 | setThemeIdForAppearance, |
| 253 | 308 | setThemeIdForBothAppearances, |
| ... | ... | @@ -261,7 +316,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 261 | 316 | |
| 262 | 317 | return ( |
| 263 | 318 | <AppearancePreferencesContext.Provider value={value}> |
| 264 | <ScopedTheme theme={activeThemeName}>{props.children}</ScopedTheme> | |
| 319 | <ScopedTheme theme={activeThemeName}> | |
| 320 | <ScopedVariables variables={themeVariables}>{props.children}</ScopedVariables> | |
| 321 | </ScopedTheme> | |
| 265 | 322 | </AppearancePreferencesContext.Provider> |
| 266 | 323 | ); |
| 267 | 324 | } |
apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx+21-6| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { memo, useId } from "react"; |
| 2 | 2 | import { Pressable, View } from "react-native"; |
| 3 | 3 | import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; |
| 4 | import { ScopedTheme } from "uniwind"; | |
| 4 | import { ScopedTheme, ScopedVariables } from "uniwind"; | |
| 5 | 5 | |
| 6 | 6 | import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; |
| 7 | 7 | |
| ... | ... | @@ -38,7 +38,12 @@ const PreviewOrb = memo(function PreviewOrb(props: { |
| 38 | 38 | const idPrefix = useId().replaceAll(":", ""); |
| 39 | 39 | const accentGradientId = `${idPrefix}-accent-glow`; |
| 40 | 40 | const actionGradientId = `${idPrefix}-action-glow`; |
| 41 | const colors = getMobileThemePreviewColors(props.themeId, props.appearance); | |
| 41 | const { systemColorPalettes } = useAppearancePreferences(); | |
| 42 | const palette = systemColorPalettes?.[props.appearance]; | |
| 43 | const colors = | |
| 44 | props.themeId === "material-you" && palette | |
| 45 | ? { canvas: palette.surface, accent: palette.primary, messageAction: palette.tertiary } | |
| 46 | : getMobileThemePreviewColors(props.themeId, props.appearance); | |
| 42 | 47 | const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance]; |
| 43 | 48 | const accentRadius = Math.hypot( |
| 44 | 49 | Math.max(spec.accent.center[0], 1 - spec.accent.center[0]), |
| ... | ... | @@ -204,15 +209,20 @@ function PreviewPane(props: { readonly compact?: boolean }) { |
| 204 | 209 | } |
| 205 | 210 | |
| 206 | 211 | function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) { |
| 212 | const { themeVariablesByAppearance } = useAppearancePreferences(); | |
| 207 | 213 | if (props.mode === "system") { |
| 208 | 214 | return ( |
| 209 | 215 | <View className="h-24 w-14 self-center rounded-[16px] border-[1.5px] border-border bg-drawer p-[3px]"> |
| 210 | 216 | <View className="flex-1 flex-row overflow-hidden rounded-[11px]"> |
| 211 | 217 | <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds.light, "light")}> |
| 212 | <PreviewPane compact /> | |
| 218 | <ScopedVariables variables={themeVariablesByAppearance.light}> | |
| 219 | <PreviewPane compact /> | |
| 220 | </ScopedVariables> | |
| 213 | 221 | </ScopedTheme> |
| 214 | 222 | <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds.dark, "dark")}> |
| 215 | <PreviewPane compact /> | |
| 223 | <ScopedVariables variables={themeVariablesByAppearance.dark}> | |
| 224 | <PreviewPane compact /> | |
| 225 | </ScopedVariables> | |
| 216 | 226 | </ScopedTheme> |
| 217 | 227 | </View> |
| 218 | 228 | <View className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full bg-foreground-muted" /> |
| ... | ... | @@ -224,7 +234,9 @@ function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: |
| 224 | 234 | <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds[props.mode], props.mode)}> |
| 225 | 235 | <View className="h-24 w-14 self-center rounded-[16px] border-[1.5px] border-border bg-drawer p-[3px]"> |
| 226 | 236 | <View className="flex-1 flex-row overflow-hidden rounded-[11px]"> |
| 227 | <PreviewPane /> | |
| 237 | <ScopedVariables variables={themeVariablesByAppearance[props.mode]}> | |
| 238 | <PreviewPane /> | |
| 239 | </ScopedVariables> | |
| 228 | 240 | </View> |
| 229 | 241 | <View className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full bg-foreground-muted" /> |
| 230 | 242 | </View> |
| ... | ... | @@ -278,6 +290,7 @@ export function ThemeAppearanceSection() { |
| 278 | 290 | setThemeMode, |
| 279 | 291 | themeIds, |
| 280 | 292 | themeMode, |
| 293 | systemColorsAvailable, | |
| 281 | 294 | } = useAppearancePreferences(); |
| 282 | 295 | |
| 283 | 296 | return ( |
| ... | ... | @@ -302,7 +315,9 @@ export function ThemeAppearanceSection() { |
| 302 | 315 | <View className="gap-3"> |
| 303 | 316 | <SectionLabel>Themes</SectionLabel> |
| 304 | 317 | <View className="flex-row flex-wrap gap-3"> |
| 305 | {MOBILE_THEME_OPTIONS.map((theme) => ( | |
| 318 | {MOBILE_THEME_OPTIONS.filter( | |
| 319 | (theme) => theme.id !== "material-you" || systemColorsAvailable, | |
| 320 | ).map((theme) => ( | |
| 306 | 321 | <ThemeCard |
| 307 | 322 | disabled={!isReady} |
| 308 | 323 | key={theme.id} |
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+1| ... | ... | @@ -37,6 +37,7 @@ export function SettingsSwitchRow(props: { |
| 37 | 37 | ) : null} |
| 38 | 38 | </View> |
| 39 | 39 | <ThemedSwitch |
| 40 | accessibilityLabel={props.label} | |
| 40 | 41 | disabled={props.disabled} |
| 41 | 42 | onValueChange={props.onValueChange} |
| 42 | 43 | value={props.value} |
apps/mobile/src/features/terminal/terminalTheme.ts+1-1| ... | ... | @@ -83,7 +83,7 @@ export function getMobileTerminalTheme( |
| 83 | 83 | scheme: TerminalAppearanceScheme, |
| 84 | 84 | ): TerminalTheme { |
| 85 | 85 | const base = getPierreTerminalTheme(scheme); |
| 86 | if (themeId === "t3-code") return base; | |
| 86 | if (themeId === "t3-code" || themeId === "material-you") return base; | |
| 87 | 87 | |
| 88 | 88 | const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; |
| 89 | 89 | const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors; |
apps/mobile/src/lib/materialYouPalette.android.ts created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | import { getMaterialColors, isDynamicColorAvailable } from "@expo/ui/jetpack-compose"; | |
| 2 | ||
| 3 | import type { MaterialYouPalettes } from "./materialYouPalette"; | |
| 4 | ||
| 5 | export const isSystemColorsAvailable = isDynamicColorAvailable; | |
| 6 | ||
| 7 | export function readSystemColorPalettes(): MaterialYouPalettes | null { | |
| 8 | if (!isDynamicColorAvailable) return null; | |
| 9 | ||
| 10 | return { | |
| 11 | light: getMaterialColors({ scheme: "light" }), | |
| 12 | dark: getMaterialColors({ scheme: "dark" }), | |
| 13 | }; | |
| 14 | } |
apps/mobile/src/lib/materialYouPalette.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | export interface MaterialYouPalette { | |
| 2 | readonly primary: string; | |
| 3 | readonly onPrimary: string; | |
| 4 | readonly primaryContainer: string; | |
| 5 | readonly onPrimaryContainer: string; | |
| 6 | readonly inversePrimary: string; | |
| 7 | readonly secondaryContainer: string; | |
| 8 | readonly onSecondaryContainer: string; | |
| 9 | readonly tertiary: string; | |
| 10 | readonly tertiaryContainer: string; | |
| 11 | readonly onTertiaryContainer: string; | |
| 12 | readonly surface: string; | |
| 13 | readonly onSurface: string; | |
| 14 | readonly onSurfaceVariant: string; | |
| 15 | readonly surfaceContainer: string; | |
| 16 | readonly surfaceContainerHigh: string; | |
| 17 | readonly surfaceContainerHighest: string; | |
| 18 | readonly surfaceContainerLow: string; | |
| 19 | readonly surfaceContainerLowest: string; | |
| 20 | readonly errorContainer: string; | |
| 21 | readonly onErrorContainer: string; | |
| 22 | readonly outline: string; | |
| 23 | readonly outlineVariant: string; | |
| 24 | readonly scrim: string; | |
| 25 | } | |
| 26 | ||
| 27 | export interface MaterialYouPalettes { | |
| 28 | readonly light: MaterialYouPalette; | |
| 29 | readonly dark: MaterialYouPalette; | |
| 30 | } | |
| 31 | ||
| 32 | export const isSystemColorsAvailable = false; | |
| 33 | ||
| 34 | export function readSystemColorPalettes(): MaterialYouPalettes | null { | |
| 35 | return null; | |
| 36 | } |
apps/mobile/src/lib/materialYouTheme.test.ts created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; | |
| 4 | import type { MaterialYouPalette } from "./materialYouPalette"; | |
| 5 | import { materialYouPaletteToMobileThemeVariables } from "./materialYouTheme"; | |
| 6 | ||
| 7 | const palette: MaterialYouPalette = { | |
| 8 | primary: "#6750A4FF", | |
| 9 | onPrimary: "#FFFFFFFF", | |
| 10 | primaryContainer: "#EADDFFFF", | |
| 11 | onPrimaryContainer: "#21005DFF", | |
| 12 | inversePrimary: "#D0BCFFFF", | |
| 13 | secondaryContainer: "#E8DEF8FF", | |
| 14 | onSecondaryContainer: "#1D192BFF", | |
| 15 | tertiary: "#7D5260FF", | |
| 16 | tertiaryContainer: "#FFD8E4FF", | |
| 17 | onTertiaryContainer: "#31111DFF", | |
| 18 | surface: "#FFFBFEFF", | |
| 19 | onSurface: "#1C1B1FFF", | |
| 20 | onSurfaceVariant: "#49454FFF", | |
| 21 | surfaceContainer: "#F3EDF7FF", | |
| 22 | surfaceContainerHigh: "#ECE6F0FF", | |
| 23 | surfaceContainerHighest: "#E6E0E9FF", | |
| 24 | surfaceContainerLow: "#F7F2FAFF", | |
| 25 | surfaceContainerLowest: "#FFFFFFFF", | |
| 26 | errorContainer: "#F9DEDCFF", | |
| 27 | onErrorContainer: "#410E0BFF", | |
| 28 | outline: "#79747EFF", | |
| 29 | outlineVariant: "#CAC4D0FF", | |
| 30 | scrim: "#000000FF", | |
| 31 | }; | |
| 32 | ||
| 33 | describe("Material You system colors", () => { | |
| 34 | it("overrides the selected theme without mutating its base variables", () => { | |
| 35 | const base = getMobileThemeRuntimeVariables("t3-code", "dark"); | |
| 36 | const snapshot = { ...base }; | |
| 37 | ||
| 38 | const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base); | |
| 39 | ||
| 40 | expect(base).toEqual(snapshot); | |
| 41 | expect(variables["--color-screen"]).toBe(palette.surface); | |
| 42 | expect(variables["--color-header"]).toBe(palette.surfaceContainerHigh); | |
| 43 | expect(variables["--color-primary"]).toBe(palette.primary); | |
| 44 | expect(variables["--color-placeholder"]).toBe("#1C1B1F9E"); | |
| 45 | }); | |
| 46 | ||
| 47 | it("uses the Messages-style RCS tones for sent messages", () => { | |
| 48 | const base = getMobileThemeRuntimeVariables("t3-code", "dark"); | |
| 49 | const dark = materialYouPaletteToMobileThemeVariables( | |
| 50 | { ...palette, inversePrimary: "#A31D8DFF" }, | |
| 51 | "dark", | |
| 52 | base, | |
| 53 | ); | |
| 54 | const light = materialYouPaletteToMobileThemeVariables( | |
| 55 | { ...palette, inversePrimary: "#A31D8DFF" }, | |
| 56 | "light", | |
| 57 | getMobileThemeRuntimeVariables("t3-code", "light"), | |
| 58 | ); | |
| 59 | ||
| 60 | expect(dark["--color-user-bubble"]).toBe("#850073FF"); | |
| 61 | expect(dark["--color-user-bubble-foreground"]).toBe("#FFD7EFFF"); | |
| 62 | expect(light["--color-user-bubble"]).toBe(palette.primary); | |
| 63 | expect(light["--color-user-bubble-foreground"]).toBe(palette.onPrimary); | |
| 64 | }); | |
| 65 | }); |
apps/mobile/src/lib/materialYouTheme.ts created+101| ... | ... | @@ -0,0 +1,101 @@ |
| 1 | import { Hct, argbFromHex, hexFromArgb } from "@material/material-color-utilities"; | |
| 2 | ||
| 3 | import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme"; | |
| 4 | import type { MaterialYouPalette } from "./materialYouPalette"; | |
| 5 | ||
| 6 | function withAlpha(color: string, alpha: number): string { | |
| 7 | const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); | |
| 8 | if (!match?.[1]) return color; | |
| 9 | const alphaByte = Math.round(Math.min(1, Math.max(0, alpha)) * 255) | |
| 10 | .toString(16) | |
| 11 | .padStart(2, "0") | |
| 12 | .toUpperCase(); | |
| 13 | return `#${match[1]}${alphaByte}`; | |
| 14 | } | |
| 15 | ||
| 16 | function materialTone(color: string, tone: number): string { | |
| 17 | const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); | |
| 18 | if (!match?.[1]) return color; | |
| 19 | ||
| 20 | const source = Hct.fromInt(argbFromHex(match[1])); | |
| 21 | return `${hexFromArgb(Hct.from(source.hue, source.chroma, tone).toInt()).toUpperCase()}FF`; | |
| 22 | } | |
| 23 | ||
| 24 | export function materialYouPaletteToMobileThemeVariables( | |
| 25 | palette: MaterialYouPalette, | |
| 26 | appearance: MobileThemeAppearance, | |
| 27 | base: MobileThemeVariables, | |
| 28 | ): MobileThemeVariables { | |
| 29 | const dark = appearance === "dark"; | |
| 30 | const userBubble = dark ? materialTone(palette.inversePrimary, 30) : palette.primary; | |
| 31 | const userBubbleForeground = dark ? materialTone(palette.inversePrimary, 90) : palette.onPrimary; | |
| 32 | ||
| 33 | return { | |
| 34 | ...base, | |
| 35 | "--color-screen": palette.surface, | |
| 36 | "--color-sheet": withAlpha(palette.surfaceContainerLow, 0.98), | |
| 37 | "--color-sheet-solid": palette.surfaceContainerLow, | |
| 38 | "--color-card": palette.surfaceContainer, | |
| 39 | "--color-card-alt": palette.surfaceContainerHigh, | |
| 40 | "--color-card-translucent": withAlpha(palette.surfaceContainer, 0.8), | |
| 41 | "--color-foreground": palette.onSurface, | |
| 42 | "--color-foreground-secondary": palette.onSurfaceVariant, | |
| 43 | "--color-foreground-muted": palette.onSurfaceVariant, | |
| 44 | "--color-foreground-tertiary": withAlpha(palette.onSurfaceVariant, 0.58), | |
| 45 | "--color-border": withAlpha(palette.outlineVariant, dark ? 0.5 : 0.65), | |
| 46 | "--color-border-subtle": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), | |
| 47 | "--color-separator": withAlpha(palette.outlineVariant, dark ? 0.24 : 0.34), | |
| 48 | "--color-subtle": withAlpha(palette.onSurface, 0.05), | |
| 49 | "--color-subtle-strong": withAlpha(palette.onSurface, 0.1), | |
| 50 | "--color-inline-skill-background": withAlpha(palette.tertiaryContainer, 0.52), | |
| 51 | "--color-inline-skill-border": withAlpha(palette.tertiary, 0.38), | |
| 52 | "--color-inline-skill-foreground": palette.onTertiaryContainer, | |
| 53 | "--color-primary": palette.primary, | |
| 54 | "--color-primary-foreground": palette.onPrimary, | |
| 55 | "--color-primary-shadow": withAlpha(palette.scrim, dark ? 0.22 : 0.18), | |
| 56 | "--color-secondary": palette.secondaryContainer, | |
| 57 | "--color-secondary-foreground": palette.onSecondaryContainer, | |
| 58 | "--color-secondary-border": withAlpha(palette.outlineVariant, dark ? 0.42 : 0.56), | |
| 59 | "--color-switch-active-track": palette.primary, | |
| 60 | "--color-switch-active-thumb": palette.onPrimary, | |
| 61 | "--color-switch-inactive-track": palette.surfaceContainerHighest, | |
| 62 | "--color-switch-inactive-thumb": palette.outline, | |
| 63 | "--color-danger": palette.errorContainer, | |
| 64 | "--color-danger-border": withAlpha(palette.onErrorContainer, dark ? 0.42 : 0.5), | |
| 65 | "--color-danger-foreground": palette.onErrorContainer, | |
| 66 | "--color-input": palette.surfaceContainerLowest, | |
| 67 | "--color-input-border": withAlpha(palette.outline, dark ? 0.42 : 0.5), | |
| 68 | "--color-sidebar-search": palette.surfaceContainerLowest, | |
| 69 | "--color-placeholder": withAlpha(palette.onSurface, 0.62), | |
| 70 | "--color-icon": palette.onSurface, | |
| 71 | "--color-icon-muted": palette.onSurfaceVariant, | |
| 72 | "--color-icon-subtle": withAlpha(palette.onSurfaceVariant, 0.68), | |
| 73 | "--color-header": palette.surfaceContainerHigh, | |
| 74 | "--color-header-border": withAlpha(palette.outlineVariant, dark ? 0.36 : 0.48), | |
| 75 | "--color-glass-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.78 : 0.72), | |
| 76 | "--color-glass-tint": withAlpha(palette.primaryContainer, dark ? 0.24 : 0.18), | |
| 77 | "--color-status-bar": palette.surfaceContainerLow, | |
| 78 | "--color-md-body": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), | |
| 79 | "--color-md-strong": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), | |
| 80 | "--color-md-link": palette.primary, | |
| 81 | "--color-md-blockquote-border": withAlpha(palette.primary, dark ? 0.34 : 0.26), | |
| 82 | "--color-md-blockquote-bg": withAlpha(palette.primaryContainer, dark ? 0.14 : 0.2), | |
| 83 | "--color-md-code-bg": withAlpha(palette.onSurface, dark ? 0.08 : 0.06), | |
| 84 | "--color-md-code-text": palette.onSurface, | |
| 85 | "--color-md-user-code-bg": withAlpha(userBubbleForeground, dark ? 0.18 : 0.22), | |
| 86 | "--color-md-user-code-text": userBubbleForeground, | |
| 87 | "--color-md-user-fence-bg": withAlpha(userBubbleForeground, dark ? 0.14 : 0.18), | |
| 88 | "--color-md-user-fence-text": userBubbleForeground, | |
| 89 | "--color-md-hr": withAlpha(palette.outlineVariant, dark ? 0.48 : 0.6), | |
| 90 | "--color-user-bubble": userBubble, | |
| 91 | "--color-user-bubble-foreground": userBubbleForeground, | |
| 92 | "--color-user-bubble-foreground-muted": withAlpha(userBubbleForeground, 0.78), | |
| 93 | "--color-user-bubble-skill-foreground": userBubbleForeground, | |
| 94 | "--color-backdrop": withAlpha(palette.scrim, dark ? 0.48 : 0.22), | |
| 95 | "--color-drawer": palette.surface, | |
| 96 | "--color-drawer-shadow": withAlpha(palette.scrim, dark ? 0.32 : 0.12), | |
| 97 | "--color-dot-separator": withAlpha(palette.onSurface, 0.2), | |
| 98 | "--color-wordmark": palette.onSurface, | |
| 99 | "--color-chevron": withAlpha(palette.onSurface, 0.2), | |
| 100 | }; | |
| 101 | } |
apps/mobile/src/lib/mobileTheme.ts+5-3| ... | ... | @@ -14,8 +14,8 @@ import { |
| 14 | 14 | } from "@t3tools/shared/themePreview"; |
| 15 | 15 | |
| 16 | 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; | |
| 17 | export const MOBILE_THEME_IDS = [...SHARED_MOBILE_THEME_IDS, "material-you"] as const; | |
| 18 | export type MobileThemeId = SharedMobileThemeId | "material-you"; | |
| 19 | 19 | export type MobileThemeAppearance = ThemeAppearance; |
| 20 | 20 | export type MobileThemeMode = MobileThemeAppearance | "system"; |
| 21 | 21 | export type MobileThemeIds = Readonly<Record<MobileThemeAppearance, MobileThemeId>>; |
| ... | ... | @@ -25,6 +25,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ |
| 25 | 25 | readonly label: string; |
| 26 | 26 | }> = [ |
| 27 | 27 | { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" }, |
| 28 | { id: "material-you", label: "Material You" }, | |
| 28 | 29 | ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), |
| 29 | 30 | ]; |
| 30 | 31 | |
| ... | ... | @@ -306,7 +307,8 @@ export function getMobileThemePreviewColors( |
| 306 | 307 | themeId: MobileThemeId, |
| 307 | 308 | appearance: MobileThemeAppearance, |
| 308 | 309 | ): ThemePreviewColors { |
| 309 | if (themeId === DEFAULT_MOBILE_THEME_ID) return STANDARD_THEME_PREVIEW_COLORS[appearance]; | |
| 310 | if (themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you") | |
| 311 | return STANDARD_THEME_PREVIEW_COLORS[appearance]; | |
| 310 | 312 | const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; |
| 311 | 313 | const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; |
| 312 | 314 | return { |
apps/mobile/src/lib/mobileThemeRuntime.ts+3-1| ... | ... | @@ -42,7 +42,9 @@ export function getMobileUniwindThemeName( |
| 42 | 42 | themeId: MobileThemeId, |
| 43 | 43 | appearance: MobileThemeAppearance, |
| 44 | 44 | ): MobileUniwindThemeName { |
| 45 | return themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`; | |
| 45 | return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" | |
| 46 | ? appearance | |
| 47 | : `${themeId}-${appearance}`; | |
| 46 | 48 | } |
| 47 | 49 | |
| 48 | 50 | /** |
apps/mobile/src/lib/mobileThemeVariables.ts+1-1| ... | ... | @@ -21,7 +21,7 @@ export function getMobileThemeRuntimeVariables( |
| 21 | 21 | themeId: MobileThemeId, |
| 22 | 22 | appearance: MobileThemeAppearance, |
| 23 | 23 | ): MobileThemeVariables { |
| 24 | return themeId === DEFAULT_MOBILE_THEME_ID | |
| 24 | return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" | |
| 25 | 25 | ? defaults[appearance] |
| 26 | 26 | : getMobileThemeVariables(themeId, appearance); |
| 27 | 27 | } |
apps/mobile/src/lib/storage.test.ts+8| ... | ... | @@ -196,6 +196,14 @@ describe("mobile connection storage", () => { |
| 196 | 196 | }); |
| 197 | 197 | }); |
| 198 | 198 | |
| 199 | it("persists Material You independently for each appearance", async () => { | |
| 200 | const themes = { lightThemeId: "material-you", darkThemeId: "ocean" } as const; | |
| 201 | await savePreferencesPatch(themes); | |
| 202 | await expect(loadPreferences()).resolves.toEqual(themes); | |
| 203 | await savePreferencesPatch({ lightThemeId: "t3-chat" }); | |
| 204 | await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); | |
| 205 | }); | |
| 206 | ||
| 199 | 207 | it("drops the removed theme transition preference", async () => { |
| 200 | 208 | mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10); |
| 201 | 209 |
apps/mobile/src/lib/useUniwindTheme.ts+1-8| ... | ... | @@ -1,8 +1,5 @@ |
| 1 | import { useMemo } from "react"; | |
| 2 | ||
| 3 | 1 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 4 | 2 | import type { MobileThemeVariables } from "./mobileTheme"; |
| 5 | import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; | |
| 6 | 3 | |
| 7 | 4 | /** |
| 8 | 5 | * Complete JS palette for native and third-party APIs that cannot consume a |
| ... | ... | @@ -13,9 +10,5 @@ import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; |
| 13 | 10 | * ScopedTheme instead of subscribing every consumer to CSS-variable updates. |
| 14 | 11 | */ |
| 15 | 12 | export function useUniwindTheme(): MobileThemeVariables { |
| 16 | const { themeAppearance, themeId } = useAppearancePreferences(); | |
| 17 | return useMemo( | |
| 18 | () => getMobileThemeRuntimeVariables(themeId, themeAppearance), | |
| 19 | [themeAppearance, themeId], | |
| 20 | ); | |
| 13 | return useAppearancePreferences().themeVariables; | |
| 21 | 14 | } |
apps/mobile/src/native/T3ComposerEditor.native.tsx+3| ... | ... | @@ -18,6 +18,7 @@ import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; |
| 18 | 18 | import { MOBILE_TYPOGRAPHY } from "../lib/typography"; |
| 19 | 19 | import { useNativePaste } from "../lib/useNativePaste"; |
| 20 | 20 | import { useFontFamily } from "../lib/useFontFamily"; |
| 21 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 21 | 22 | import { useUniwindTheme } from "../lib/useUniwindTheme"; |
| 22 | 23 | import { |
| 23 | 24 | acknowledgeComposerNativeEvent, |
| ... | ... | @@ -211,7 +212,9 @@ export function ComposerEditor({ |
| 211 | 212 | }, |
| 212 | 213 | [], |
| 213 | 214 | ); |
| 215 | const { systemColorsActive } = useAppearancePreferences(); | |
| 214 | 216 | const themeJson = JSON.stringify({ |
| 217 | selection: systemColorsActive ? theme["--color-primary"] : null, | |
| 215 | 218 | text: theme["--color-foreground"], |
| 216 | 219 | placeholder: theme["--color-placeholder"], |
| 217 | 220 | chipBackground: theme["--color-subtle"], |
docs/user/appearance.md+4| ... | ... | @@ -7,6 +7,10 @@ within each theme. Appearance preferences are saved separately on each device or |
| 7 | 7 | Mobile has its own themes and text, code, and terminal preferences. It does not follow environment |
| 8 | 8 | themes or defaults. |
| 9 | 9 | |
| 10 | On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from | |
| 11 | your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You | |
| 12 | can be selected separately for light and dark appearances. | |
| 13 | ||
| 10 | 14 | ## Motion |
| 11 | 15 | |
| 12 | 16 | The main sidebar, right panel, and terminal drawer open and close immediately by default. Move the |
pnpm-lock.yaml+8| ... | ... | @@ -247,6 +247,9 @@ importers: |
| 247 | 247 | '@legendapp/list': |
| 248 | 248 | specifier: 'catalog:' |
| 249 | 249 | version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 250 | '@material/material-color-utilities': | |
| 251 | specifier: 0.3.0 | |
| 252 | version: 0.3.0 | |
| 250 | 253 | '@noble/curves': |
| 251 | 254 | specifier: 'catalog:' |
| 252 | 255 | version: 1.9.1 |
| ... | ... | @@ -3412,6 +3415,9 @@ packages: |
| 3412 | 3415 | resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} |
| 3413 | 3416 | engines: {node: '>= 10.0.0'} |
| 3414 | 3417 | |
| 3418 | '@material/material-color-utilities@0.3.0': | |
| 3419 | resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} | |
| 3420 | ||
| 3415 | 3421 | '@modelcontextprotocol/sdk@1.29.0': |
| 3416 | 3422 | resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} |
| 3417 | 3423 | engines: {node: '>=18'} |
| ... | ... | @@ -14208,6 +14214,8 @@ snapshots: |
| 14208 | 14214 | transitivePeerDependencies: |
| 14209 | 14215 | - supports-color |
| 14210 | 14216 | |
| 14217 | '@material/material-color-utilities@0.3.0': {} | |
| 14218 | ||
| 14211 | 14219 | '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': |
| 14212 | 14220 | dependencies: |
| 14213 | 14221 | '@hono/node-server': 1.19.14(hono@4.12.27) |