authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-08 09:27:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 09:27:29-07:00
log579266caa89238611ddb974ffebe9968c6a6e5d5
tree36bb21155962260c99071defad07a8fcf4e76d25
parent47eed9face4186635488fba5f2494eddd13f6491
signature Signed by PGP key B5690EEEBB952194

feat(mobile): use Android wallpaper colors (#10691)

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,11 +7,13 @@ import android.graphics.Canvas
7import android.graphics.Paint7import android.graphics.Paint
8import android.graphics.RectF8import android.graphics.RectF
9import android.graphics.Typeface9import android.graphics.Typeface
10import android.os.Build
10import android.text.Editable11import android.text.Editable
11import android.text.InputType12import android.text.InputType
12import android.text.Spanned13import android.text.Spanned
13import android.text.TextWatcher14import android.text.TextWatcher
14import android.text.style.ReplacementSpan15import android.text.style.ReplacementSpan
16import android.util.TypedValue
15import android.view.Gravity17import android.view.Gravity
16import android.view.ViewGroup18import android.view.ViewGroup
17import android.view.inputmethod.InputMethodManager19import android.view.inputmethod.InputMethodManager
...@@ -27,6 +29,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(...@@ -27,6 +29,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
27 appContext29 appContext
28) {30) {
29 private val editor = SelectionAwareEditText(context)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 private val onComposerChange by EventDispatcher()37 private val onComposerChange by EventDispatcher()
31 private val onComposerSelectionChange by EventDispatcher()38 private val onComposerSelectionChange by EventDispatcher()
32 private val onComposerFocus by EventDispatcher()39 private val onComposerFocus by EventDispatcher()
...@@ -152,6 +159,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(...@@ -152,6 +159,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
152 val theme = JSONObject(themeJson)159 val theme = JSONObject(themeJson)
153 editor.setTextColor(parseColor(theme.optString("text"), Color.BLACK))160 editor.setTextColor(parseColor(theme.optString("text"), Color.BLACK))
154 editor.setHintTextColor(parseColor(theme.optString("placeholder"), Color.GRAY))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 chipTheme = ComposerChipTheme(167 chipTheme = ComposerChipTheme(
156 chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground),168 chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground),
157 chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder),169 chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder),
...@@ -277,6 +289,21 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(...@@ -277,6 +289,21 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
277 editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f)289 editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f)
278 }290 }
279291
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 private fun currentSelectionPayload(): Map<String, Int> =307 private fun currentSelectionPayload(): Map<String, Int> =
281 mapOf(308 mapOf(
282 "start" to editor.selectionStart.coerceAtLeast(0),309 "start" to editor.selectionStart.coerceAtLeast(0),
...@@ -335,12 +362,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(...@@ -335,12 +362,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
335362
336 private fun parseColor(value: String, fallback: Int): Int =363 private fun parseColor(value: String, fallback: Int): Int =
337 try {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 } catch (_: Exception) {371 } catch (_: Exception) {
340 fallback372 fallback
341 }373 }
342}374}
343375
376private fun Int.withAlpha(alpha: Int): Int = (this and 0x00FFFFFF) or (alpha shl 24)
377
378private 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
344private data class ComposerToken(383private data class ComposerToken(
345 val type: String,384 val type: String,
346 val source: String,385 val source: String,
apps/mobile/package.json+1
...@@ -51,6 +51,7 @@...@@ -51,6 +51,7 @@
51 "@expo/metro-runtime": "~57.0.14",51 "@expo/metro-runtime": "~57.0.14",
52 "@expo/ui": "~57.0.14",52 "@expo/ui": "~57.0.14",
53 "@legendapp/list": "catalog:",53 "@legendapp/list": "catalog:",
54 "@material/material-color-utilities": "0.3.0",
54 "@noble/curves": "catalog:",55 "@noble/curves": "catalog:",
55 "@pierre/diffs": "catalog:",56 "@pierre/diffs": "catalog:",
56 "@react-native-ai/apple": "0.12.0",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,7 +66,7 @@ function filesPatch(paths: ReadonlyArray<string>) {
66}66}
6767
68function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) {68function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) {
69 return themeId === DEFAULT_MOBILE_THEME_ID69 return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you"
70 ? readDefaultMobileThemeVariables(appearance)70 ? readDefaultMobileThemeVariables(appearance)
71 : getMobileThemeVariables(themeId, appearance);71 : getMobileThemeVariables(themeId, appearance);
72}72}
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+60-3
...@@ -3,17 +3,19 @@ import {...@@ -3,17 +3,19 @@ import {
3 startTransition,3 startTransition,
4 use,4 use,
5 useCallback,5 useCallback,
6 useEffect,
7 useState,
6 useLayoutEffect,8 useLayoutEffect,
7 useMemo,9 useMemo,
8 useRef,10 useRef,
9 type ReactNode,11 type ReactNode,
10} from "react";12} from "react";
11import { Appearance, useColorScheme } from "react-native";13import { AppState, Appearance, useColorScheme } from "react-native";
1214
13import { useAtomSet, useAtomValue } from "@effect/atom-react";15import { useAtomSet, useAtomValue } from "@effect/atom-react";
14import { AsyncResult } from "effect/unstable/reactivity";16import { AsyncResult } from "effect/unstable/reactivity";
1517
16import { ScopedTheme, Uniwind } from "uniwind";18import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind";
1719
18import {20import {
19 resolveAppearance,21 resolveAppearance,
...@@ -22,6 +24,10 @@ import {...@@ -22,6 +24,10 @@ import {
22} from "../../../lib/appearancePreferences";24} from "../../../lib/appearancePreferences";
23import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";25import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
24import type { Preferences } from "../../../persistence/mobile-preferences";26import type { Preferences } from "../../../persistence/mobile-preferences";
27import { isSystemColorsAvailable, readSystemColorPalettes } from "../../../lib/materialYouPalette";
28import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme";
29import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables";
30import type { MobileThemeVariables } from "../../../lib/mobileTheme";
25import {31import {
26 createMobileThemePairPatch,32 createMobileThemePairPatch,
27 createMobileThemeSelectionPatch,33 createMobileThemeSelectionPatch,
...@@ -45,6 +51,13 @@ interface AppearancePreferencesContextValue {...@@ -45,6 +51,13 @@ interface AppearancePreferencesContextValue {
45 readonly themeIds: MobileThemeIds;51 readonly themeIds: MobileThemeIds;
46 readonly themeMode: MobileThemeMode;52 readonly themeMode: MobileThemeMode;
47 readonly themeAppearance: MobileThemeAppearance;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 readonly isReady: boolean;61 readonly isReady: boolean;
49 readonly setThemeIdForAppearance: (62 readonly setThemeIdForAppearance: (
50 appearance: MobileThemeAppearance,63 appearance: MobileThemeAppearance,
...@@ -81,6 +94,39 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -81,6 +94,39 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
81 [resolvedThemeIds.dark, resolvedThemeIds.light],94 [resolvedThemeIds.dark, resolvedThemeIds.light],
82 );95 );
83 const themeId = themeIds[themeAppearance];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 const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance);130 const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance);
85 const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences;131 const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences;
86 const appearance = useMemo(132 const appearance = useMemo(
...@@ -233,6 +279,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -233,6 +279,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
233 themeIds,279 themeIds,
234 themeMode,280 themeMode,
235 themeAppearance,281 themeAppearance,
282 systemColorsAvailable: isSystemColorsAvailable,
283 systemColorsActive,
284 themeVariables,
285 themeVariablesByAppearance,
286 systemColorPalettes,
236 isReady,287 isReady,
237 setThemeIdForAppearance,288 setThemeIdForAppearance,
238 setThemeIdForBothAppearances,289 setThemeIdForBothAppearances,
...@@ -248,6 +299,10 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -248,6 +299,10 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
248 themeIds,299 themeIds,
249 themeMode,300 themeMode,
250 themeAppearance,301 themeAppearance,
302 systemColorsActive,
303 themeVariables,
304 themeVariablesByAppearance,
305 systemColorPalettes,
251 isReady,306 isReady,
252 setThemeIdForAppearance,307 setThemeIdForAppearance,
253 setThemeIdForBothAppearances,308 setThemeIdForBothAppearances,
...@@ -261,7 +316,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -261,7 +316,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
261316
262 return (317 return (
263 <AppearancePreferencesContext.Provider value={value}>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 </AppearancePreferencesContext.Provider>322 </AppearancePreferencesContext.Provider>
266 );323 );
267}324}
apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx+21-6
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { memo, useId } from "react";1import { memo, useId } from "react";
2import { Pressable, View } from "react-native";2import { Pressable, View } from "react-native";
3import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";3import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
4import { ScopedTheme } from "uniwind";4import { ScopedTheme, ScopedVariables } from "uniwind";
55
6import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview";6import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview";
77
...@@ -38,7 +38,12 @@ const PreviewOrb = memo(function PreviewOrb(props: {...@@ -38,7 +38,12 @@ const PreviewOrb = memo(function PreviewOrb(props: {
38 const idPrefix = useId().replaceAll(":", "");38 const idPrefix = useId().replaceAll(":", "");
39 const accentGradientId = `${idPrefix}-accent-glow`;39 const accentGradientId = `${idPrefix}-accent-glow`;
40 const actionGradientId = `${idPrefix}-action-glow`;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 const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance];47 const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance];
43 const accentRadius = Math.hypot(48 const accentRadius = Math.hypot(
44 Math.max(spec.accent.center[0], 1 - spec.accent.center[0]),49 Math.max(spec.accent.center[0], 1 - spec.accent.center[0]),
...@@ -204,15 +209,20 @@ function PreviewPane(props: { readonly compact?: boolean }) {...@@ -204,15 +209,20 @@ function PreviewPane(props: { readonly compact?: boolean }) {
204}209}
205210
206function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) {211function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) {
212 const { themeVariablesByAppearance } = useAppearancePreferences();
207 if (props.mode === "system") {213 if (props.mode === "system") {
208 return (214 return (
209 <View className="h-24 w-14 self-center rounded-[16px] border-[1.5px] border-border bg-drawer p-[3px]">215 <View className="h-24 w-14 self-center rounded-[16px] border-[1.5px] border-border bg-drawer p-[3px]">
210 <View className="flex-1 flex-row overflow-hidden rounded-[11px]">216 <View className="flex-1 flex-row overflow-hidden rounded-[11px]">
211 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds.light, "light")}>217 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds.light, "light")}>
212 <PreviewPane compact />218 <ScopedVariables variables={themeVariablesByAppearance.light}>
219 <PreviewPane compact />
220 </ScopedVariables>
213 </ScopedTheme>221 </ScopedTheme>
214 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds.dark, "dark")}>222 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds.dark, "dark")}>
215 <PreviewPane compact />223 <ScopedVariables variables={themeVariablesByAppearance.dark}>
224 <PreviewPane compact />
225 </ScopedVariables>
216 </ScopedTheme>226 </ScopedTheme>
217 </View>227 </View>
218 <View className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full bg-foreground-muted" />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,7 +234,9 @@ function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds:
224 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds[props.mode], props.mode)}>234 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds[props.mode], props.mode)}>
225 <View className="h-24 w-14 self-center rounded-[16px] border-[1.5px] border-border bg-drawer p-[3px]">235 <View className="h-24 w-14 self-center rounded-[16px] border-[1.5px] border-border bg-drawer p-[3px]">
226 <View className="flex-1 flex-row overflow-hidden rounded-[11px]">236 <View className="flex-1 flex-row overflow-hidden rounded-[11px]">
227 <PreviewPane />237 <ScopedVariables variables={themeVariablesByAppearance[props.mode]}>
238 <PreviewPane />
239 </ScopedVariables>
228 </View>240 </View>
229 <View className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full bg-foreground-muted" />241 <View className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full bg-foreground-muted" />
230 </View>242 </View>
...@@ -278,6 +290,7 @@ export function ThemeAppearanceSection() {...@@ -278,6 +290,7 @@ export function ThemeAppearanceSection() {
278 setThemeMode,290 setThemeMode,
279 themeIds,291 themeIds,
280 themeMode,292 themeMode,
293 systemColorsAvailable,
281 } = useAppearancePreferences();294 } = useAppearancePreferences();
282295
283 return (296 return (
...@@ -302,7 +315,9 @@ export function ThemeAppearanceSection() {...@@ -302,7 +315,9 @@ export function ThemeAppearanceSection() {
302 <View className="gap-3">315 <View className="gap-3">
303 <SectionLabel>Themes</SectionLabel>316 <SectionLabel>Themes</SectionLabel>
304 <View className="flex-row flex-wrap gap-3">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 <ThemeCard321 <ThemeCard
307 disabled={!isReady}322 disabled={!isReady}
308 key={theme.id}323 key={theme.id}
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+1
...@@ -37,6 +37,7 @@ export function SettingsSwitchRow(props: {...@@ -37,6 +37,7 @@ export function SettingsSwitchRow(props: {
37 ) : null}37 ) : null}
38 </View>38 </View>
39 <ThemedSwitch39 <ThemedSwitch
40 accessibilityLabel={props.label}
40 disabled={props.disabled}41 disabled={props.disabled}
41 onValueChange={props.onValueChange}42 onValueChange={props.onValueChange}
42 value={props.value}43 value={props.value}
apps/mobile/src/features/terminal/terminalTheme.ts+1-1
...@@ -83,7 +83,7 @@ export function getMobileTerminalTheme(...@@ -83,7 +83,7 @@ export function getMobileTerminalTheme(
83 scheme: TerminalAppearanceScheme,83 scheme: TerminalAppearanceScheme,
84): TerminalTheme {84): TerminalTheme {
85 const base = getPierreTerminalTheme(scheme);85 const base = getPierreTerminalTheme(scheme);
86 if (themeId === "t3-code") return base;86 if (themeId === "t3-code" || themeId === "material-you") return base;
8787
88 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];88 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
89 const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors;89 const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors;
apps/mobile/src/lib/materialYouPalette.android.ts created+14
...@@ -0,0 +1,14 @@
1import { getMaterialColors, isDynamicColorAvailable } from "@expo/ui/jetpack-compose";
2
3import type { MaterialYouPalettes } from "./materialYouPalette";
4
5export const isSystemColorsAvailable = isDynamicColorAvailable;
6
7export 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 @@
1export 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
27export interface MaterialYouPalettes {
28 readonly light: MaterialYouPalette;
29 readonly dark: MaterialYouPalette;
30}
31
32export const isSystemColorsAvailable = false;
33
34export function readSystemColorPalettes(): MaterialYouPalettes | null {
35 return null;
36}
apps/mobile/src/lib/materialYouTheme.test.ts created+65
...@@ -0,0 +1,65 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
4import type { MaterialYouPalette } from "./materialYouPalette";
5import { materialYouPaletteToMobileThemeVariables } from "./materialYouTheme";
6
7const 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
33describe("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 @@
1import { Hct, argbFromHex, hexFromArgb } from "@material/material-color-utilities";
2
3import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme";
4import type { MaterialYouPalette } from "./materialYouPalette";
5
6function 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
16function 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
24export 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,8 +14,8 @@ import {
14} from "@t3tools/shared/themePreview";14} from "@t3tools/shared/themePreview";
1515
16export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID;16export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID;
17export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS;17export const MOBILE_THEME_IDS = [...SHARED_MOBILE_THEME_IDS, "material-you"] as const;
18export type MobileThemeId = SharedMobileThemeId;18export type MobileThemeId = SharedMobileThemeId | "material-you";
19export type MobileThemeAppearance = ThemeAppearance;19export type MobileThemeAppearance = ThemeAppearance;
20export type MobileThemeMode = MobileThemeAppearance | "system";20export type MobileThemeMode = MobileThemeAppearance | "system";
21export type MobileThemeIds = Readonly<Record<MobileThemeAppearance, MobileThemeId>>;21export type MobileThemeIds = Readonly<Record<MobileThemeAppearance, MobileThemeId>>;
...@@ -25,6 +25,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{...@@ -25,6 +25,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{
25 readonly label: string;25 readonly label: string;
26}> = [26}> = [
27 { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" },27 { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" },
28 { id: "material-you", label: "Material You" },
28 ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })),29 ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })),
29];30];
3031
...@@ -306,7 +307,8 @@ export function getMobileThemePreviewColors(...@@ -306,7 +307,8 @@ export function getMobileThemePreviewColors(
306 themeId: MobileThemeId,307 themeId: MobileThemeId,
307 appearance: MobileThemeAppearance,308 appearance: MobileThemeAppearance,
308): ThemePreviewColors {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 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];312 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
311 const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;313 const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
312 return {314 return {
apps/mobile/src/lib/mobileThemeRuntime.ts+3-1
...@@ -42,7 +42,9 @@ export function getMobileUniwindThemeName(...@@ -42,7 +42,9 @@ export function getMobileUniwindThemeName(
42 themeId: MobileThemeId,42 themeId: MobileThemeId,
43 appearance: MobileThemeAppearance,43 appearance: MobileThemeAppearance,
44): MobileUniwindThemeName {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}
4749
48/**50/**
apps/mobile/src/lib/mobileThemeVariables.ts+1-1
...@@ -21,7 +21,7 @@ export function getMobileThemeRuntimeVariables(...@@ -21,7 +21,7 @@ export function getMobileThemeRuntimeVariables(
21 themeId: MobileThemeId,21 themeId: MobileThemeId,
22 appearance: MobileThemeAppearance,22 appearance: MobileThemeAppearance,
23): MobileThemeVariables {23): MobileThemeVariables {
24 return themeId === DEFAULT_MOBILE_THEME_ID24 return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you"
25 ? defaults[appearance]25 ? defaults[appearance]
26 : getMobileThemeVariables(themeId, appearance);26 : getMobileThemeVariables(themeId, appearance);
27}27}
apps/mobile/src/lib/storage.test.ts+8
...@@ -196,6 +196,14 @@ describe("mobile connection storage", () => {...@@ -196,6 +196,14 @@ describe("mobile connection storage", () => {
196 });196 });
197 });197 });
198198
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 it("drops the removed theme transition preference", async () => {207 it("drops the removed theme transition preference", async () => {
200 mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10);208 mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10);
201209
apps/mobile/src/lib/useUniwindTheme.ts+1-8
...@@ -1,8 +1,5 @@...@@ -1,8 +1,5 @@
1import { useMemo } from "react";
2
3import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";1import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
4import type { MobileThemeVariables } from "./mobileTheme";2import type { MobileThemeVariables } from "./mobileTheme";
5import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
63
7/**4/**
8 * Complete JS palette for native and third-party APIs that cannot consume a5 * Complete JS palette for native and third-party APIs that cannot consume a
...@@ -13,9 +10,5 @@ import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";...@@ -13,9 +10,5 @@ import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
13 * ScopedTheme instead of subscribing every consumer to CSS-variable updates.10 * ScopedTheme instead of subscribing every consumer to CSS-variable updates.
14 */11 */
15export function useUniwindTheme(): MobileThemeVariables {12export function useUniwindTheme(): MobileThemeVariables {
16 const { themeAppearance, themeId } = useAppearancePreferences();13 return useAppearancePreferences().themeVariables;
17 return useMemo(
18 () => getMobileThemeRuntimeVariables(themeId, themeAppearance),
19 [themeAppearance, themeId],
20 );
21}14}
apps/mobile/src/native/T3ComposerEditor.native.tsx+3
...@@ -18,6 +18,7 @@ import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";...@@ -18,6 +18,7 @@ import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
18import { MOBILE_TYPOGRAPHY } from "../lib/typography";18import { MOBILE_TYPOGRAPHY } from "../lib/typography";
19import { useNativePaste } from "../lib/useNativePaste";19import { useNativePaste } from "../lib/useNativePaste";
20import { useFontFamily } from "../lib/useFontFamily";20import { useFontFamily } from "../lib/useFontFamily";
21import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
21import { useUniwindTheme } from "../lib/useUniwindTheme";22import { useUniwindTheme } from "../lib/useUniwindTheme";
22import {23import {
23 acknowledgeComposerNativeEvent,24 acknowledgeComposerNativeEvent,
...@@ -211,7 +212,9 @@ export function ComposerEditor({...@@ -211,7 +212,9 @@ export function ComposerEditor({
211 },212 },
212 [],213 [],
213 );214 );
215 const { systemColorsActive } = useAppearancePreferences();
214 const themeJson = JSON.stringify({216 const themeJson = JSON.stringify({
217 selection: systemColorsActive ? theme["--color-primary"] : null,
215 text: theme["--color-foreground"],218 text: theme["--color-foreground"],
216 placeholder: theme["--color-placeholder"],219 placeholder: theme["--color-placeholder"],
217 chipBackground: theme["--color-subtle"],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,6 +7,10 @@ within each theme. Appearance preferences are saved separately on each device or
7Mobile has its own themes and text, code, and terminal preferences. It does not follow environment7Mobile has its own themes and text, code, and terminal preferences. It does not follow environment
8themes or defaults.8themes or defaults.
99
10On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from
11your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You
12can be selected separately for light and dark appearances.
13
10## Motion14## Motion
1115
12The main sidebar, right panel, and terminal drawer open and close immediately by default. Move the16The 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,6 +247,9 @@ importers:
247 '@legendapp/list':247 '@legendapp/list':
248 specifier: 'catalog:'248 specifier: 'catalog:'
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)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 '@noble/curves':253 '@noble/curves':
251 specifier: 'catalog:'254 specifier: 'catalog:'
252 version: 1.9.1255 version: 1.9.1
...@@ -3412,6 +3415,9 @@ packages:...@@ -3412,6 +3415,9 @@ packages:
3412 resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}3415 resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
3413 engines: {node: '>= 10.0.0'}3416 engines: {node: '>= 10.0.0'}
34143417
3418 '@material/material-color-utilities@0.3.0':
3419 resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==}
3420
3415 '@modelcontextprotocol/sdk@1.29.0':3421 '@modelcontextprotocol/sdk@1.29.0':
3416 resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}3422 resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
3417 engines: {node: '>=18'}3423 engines: {node: '>=18'}
...@@ -14208,6 +14214,8 @@ snapshots:...@@ -14208,6 +14214,8 @@ snapshots:
14208 transitivePeerDependencies:14214 transitivePeerDependencies:
14209 - supports-color14215 - supports-color
1421014216
14217 '@material/material-color-utilities@0.3.0': {}
14218
14211 '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)':14219 '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)':
14212 dependencies:14220 dependencies:
14213 '@hono/node-server': 1.19.14(hono@4.12.27)14221 '@hono/node-server': 1.19.14(hono@4.12.27)