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
77import android.graphics.Paint
88import android.graphics.RectF
99import android.graphics.Typeface
10import android.os.Build
1011import android.text.Editable
1112import android.text.InputType
1213import android.text.Spanned
1314import android.text.TextWatcher
1415import android.text.style.ReplacementSpan
16import android.util.TypedValue
1517import android.view.Gravity
1618import android.view.ViewGroup
1719import android.view.inputmethod.InputMethodManager
......@@ -27,6 +29,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
2729 appContext
2830) {
2931 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 )
3037 private val onComposerChange by EventDispatcher()
3138 private val onComposerSelectionChange by EventDispatcher()
3239 private val onComposerFocus by EventDispatcher()
......@@ -152,6 +159,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
152159 val theme = JSONObject(themeJson)
153160 editor.setTextColor(parseColor(theme.optString("text"), Color.BLACK))
154161 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 }
155167 chipTheme = ComposerChipTheme(
156168 chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground),
157169 chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder),
......@@ -277,6 +289,21 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
277289 editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f)
278290 }
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
280307 private fun currentSelectionPayload(): Map<String, Int> =
281308 mapOf(
282309 "start" to editor.selectionStart.coerceAtLeast(0),
......@@ -335,12 +362,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
335362
336363 private fun parseColor(value: String, fallback: Int): Int =
337364 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)
339371 } catch (_: Exception) {
340372 fallback
341373 }
342374}
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
344383private data class ComposerToken(
345384 val type: String,
346385 val source: String,
apps/mobile/package.json+1
......@@ -51,6 +51,7 @@
5151 "@expo/metro-runtime": "~57.0.14",
5252 "@expo/ui": "~57.0.14",
5353 "@legendapp/list": "catalog:",
54 "@material/material-color-utilities": "0.3.0",
5455 "@noble/curves": "catalog:",
5556 "@pierre/diffs": "catalog:",
5657 "@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>) {
6666}
6767
6868function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) {
69 return themeId === DEFAULT_MOBILE_THEME_ID
69 return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you"
7070 ? readDefaultMobileThemeVariables(appearance)
7171 : getMobileThemeVariables(themeId, appearance);
7272}
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+60-3
......@@ -3,17 +3,19 @@ import {
33 startTransition,
44 use,
55 useCallback,
6 useEffect,
7 useState,
68 useLayoutEffect,
79 useMemo,
810 useRef,
911 type ReactNode,
1012} from "react";
11import { Appearance, useColorScheme } from "react-native";
13import { AppState, Appearance, useColorScheme } from "react-native";
1214
1315import { useAtomSet, useAtomValue } from "@effect/atom-react";
1416import { AsyncResult } from "effect/unstable/reactivity";
1517
16import { ScopedTheme, Uniwind } from "uniwind";
18import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind";
1719
1820import {
1921 resolveAppearance,
......@@ -22,6 +24,10 @@ import {
2224} from "../../../lib/appearancePreferences";
2325import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
2426import 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";
2531import {
2632 createMobileThemePairPatch,
2733 createMobileThemeSelectionPatch,
......@@ -45,6 +51,13 @@ interface AppearancePreferencesContextValue {
4551 readonly themeIds: MobileThemeIds;
4652 readonly themeMode: MobileThemeMode;
4753 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>;
4861 readonly isReady: boolean;
4962 readonly setThemeIdForAppearance: (
5063 appearance: MobileThemeAppearance,
......@@ -81,6 +94,39 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
8194 [resolvedThemeIds.dark, resolvedThemeIds.light],
8295 );
8396 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];
84130 const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance);
85131 const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences;
86132 const appearance = useMemo(
......@@ -233,6 +279,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
233279 themeIds,
234280 themeMode,
235281 themeAppearance,
282 systemColorsAvailable: isSystemColorsAvailable,
283 systemColorsActive,
284 themeVariables,
285 themeVariablesByAppearance,
286 systemColorPalettes,
236287 isReady,
237288 setThemeIdForAppearance,
238289 setThemeIdForBothAppearances,
......@@ -248,6 +299,10 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
248299 themeIds,
249300 themeMode,
250301 themeAppearance,
302 systemColorsActive,
303 themeVariables,
304 themeVariablesByAppearance,
305 systemColorPalettes,
251306 isReady,
252307 setThemeIdForAppearance,
253308 setThemeIdForBothAppearances,
......@@ -261,7 +316,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
261316
262317 return (
263318 <AppearancePreferencesContext.Provider value={value}>
264 <ScopedTheme theme={activeThemeName}>{props.children}</ScopedTheme>
319 <ScopedTheme theme={activeThemeName}>
320 <ScopedVariables variables={themeVariables}>{props.children}</ScopedVariables>
321 </ScopedTheme>
265322 </AppearancePreferencesContext.Provider>
266323 );
267324}
apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx+21-6
......@@ -1,7 +1,7 @@
11import { memo, useId } from "react";
22import { Pressable, View } from "react-native";
33import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
4import { ScopedTheme } from "uniwind";
4import { ScopedTheme, ScopedVariables } from "uniwind";
55
66import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview";
77
......@@ -38,7 +38,12 @@ const PreviewOrb = memo(function PreviewOrb(props: {
3838 const idPrefix = useId().replaceAll(":", "");
3939 const accentGradientId = `${idPrefix}-accent-glow`;
4040 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);
4247 const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance];
4348 const accentRadius = Math.hypot(
4449 Math.max(spec.accent.center[0], 1 - spec.accent.center[0]),
......@@ -204,15 +209,20 @@ function PreviewPane(props: { readonly compact?: boolean }) {
204209}
205210
206211function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) {
212 const { themeVariablesByAppearance } = useAppearancePreferences();
207213 if (props.mode === "system") {
208214 return (
209215 <View className="h-24 w-14 self-center rounded-[16px] border-[1.5px] border-border bg-drawer p-[3px]">
210216 <View className="flex-1 flex-row overflow-hidden rounded-[11px]">
211217 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds.light, "light")}>
212 <PreviewPane compact />
218 <ScopedVariables variables={themeVariablesByAppearance.light}>
219 <PreviewPane compact />
220 </ScopedVariables>
213221 </ScopedTheme>
214222 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds.dark, "dark")}>
215 <PreviewPane compact />
223 <ScopedVariables variables={themeVariablesByAppearance.dark}>
224 <PreviewPane compact />
225 </ScopedVariables>
216226 </ScopedTheme>
217227 </View>
218228 <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:
224234 <ScopedTheme theme={getMobileUniwindThemeName(props.themeIds[props.mode], props.mode)}>
225235 <View className="h-24 w-14 self-center rounded-[16px] border-[1.5px] border-border bg-drawer p-[3px]">
226236 <View className="flex-1 flex-row overflow-hidden rounded-[11px]">
227 <PreviewPane />
237 <ScopedVariables variables={themeVariablesByAppearance[props.mode]}>
238 <PreviewPane />
239 </ScopedVariables>
228240 </View>
229241 <View className="absolute bottom-[6px] left-1/2 h-1 w-4 -translate-x-1/2 rounded-full bg-foreground-muted" />
230242 </View>
......@@ -278,6 +290,7 @@ export function ThemeAppearanceSection() {
278290 setThemeMode,
279291 themeIds,
280292 themeMode,
293 systemColorsAvailable,
281294 } = useAppearancePreferences();
282295
283296 return (
......@@ -302,7 +315,9 @@ export function ThemeAppearanceSection() {
302315 <View className="gap-3">
303316 <SectionLabel>Themes</SectionLabel>
304317 <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) => (
306321 <ThemeCard
307322 disabled={!isReady}
308323 key={theme.id}
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+1
......@@ -37,6 +37,7 @@ export function SettingsSwitchRow(props: {
3737 ) : null}
3838 </View>
3939 <ThemedSwitch
40 accessibilityLabel={props.label}
4041 disabled={props.disabled}
4142 onValueChange={props.onValueChange}
4243 value={props.value}
apps/mobile/src/features/terminal/terminalTheme.ts+1-1
......@@ -83,7 +83,7 @@ export function getMobileTerminalTheme(
8383 scheme: TerminalAppearanceScheme,
8484): TerminalTheme {
8585 const base = getPierreTerminalTheme(scheme);
86 if (themeId === "t3-code") return base;
86 if (themeId === "t3-code" || themeId === "material-you") return base;
8787
8888 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
8989 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 {
1414} from "@t3tools/shared/themePreview";
1515
1616export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID;
17export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS;
18export type MobileThemeId = SharedMobileThemeId;
17export const MOBILE_THEME_IDS = [...SHARED_MOBILE_THEME_IDS, "material-you"] as const;
18export type MobileThemeId = SharedMobileThemeId | "material-you";
1919export type MobileThemeAppearance = ThemeAppearance;
2020export type MobileThemeMode = MobileThemeAppearance | "system";
2121export type MobileThemeIds = Readonly<Record<MobileThemeAppearance, MobileThemeId>>;
......@@ -25,6 +25,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{
2525 readonly label: string;
2626}> = [
2727 { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" },
28 { id: "material-you", label: "Material You" },
2829 ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })),
2930];
3031
......@@ -306,7 +307,8 @@ export function getMobileThemePreviewColors(
306307 themeId: MobileThemeId,
307308 appearance: MobileThemeAppearance,
308309): 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];
310312 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
311313 const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
312314 return {
apps/mobile/src/lib/mobileThemeRuntime.ts+3-1
......@@ -42,7 +42,9 @@ export function getMobileUniwindThemeName(
4242 themeId: MobileThemeId,
4343 appearance: MobileThemeAppearance,
4444): 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}`;
4648}
4749
4850/**
apps/mobile/src/lib/mobileThemeVariables.ts+1-1
......@@ -21,7 +21,7 @@ export function getMobileThemeRuntimeVariables(
2121 themeId: MobileThemeId,
2222 appearance: MobileThemeAppearance,
2323): MobileThemeVariables {
24 return themeId === DEFAULT_MOBILE_THEME_ID
24 return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you"
2525 ? defaults[appearance]
2626 : getMobileThemeVariables(themeId, appearance);
2727}
apps/mobile/src/lib/storage.test.ts+8
......@@ -196,6 +196,14 @@ describe("mobile connection storage", () => {
196196 });
197197 });
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
199207 it("drops the removed theme transition preference", async () => {
200208 mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10);
201209
apps/mobile/src/lib/useUniwindTheme.ts+1-8
......@@ -1,8 +1,5 @@
1import { useMemo } from "react";
2
31import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
42import type { MobileThemeVariables } from "./mobileTheme";
5import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
63
74/**
85 * Complete JS palette for native and third-party APIs that cannot consume a
......@@ -13,9 +10,5 @@ import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
1310 * ScopedTheme instead of subscribing every consumer to CSS-variable updates.
1411 */
1512export function useUniwindTheme(): MobileThemeVariables {
16 const { themeAppearance, themeId } = useAppearancePreferences();
17 return useMemo(
18 () => getMobileThemeRuntimeVariables(themeId, themeAppearance),
19 [themeAppearance, themeId],
20 );
13 return useAppearancePreferences().themeVariables;
2114}
apps/mobile/src/native/T3ComposerEditor.native.tsx+3
......@@ -18,6 +18,7 @@ import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
1818import { MOBILE_TYPOGRAPHY } from "../lib/typography";
1919import { useNativePaste } from "../lib/useNativePaste";
2020import { useFontFamily } from "../lib/useFontFamily";
21import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
2122import { useUniwindTheme } from "../lib/useUniwindTheme";
2223import {
2324 acknowledgeComposerNativeEvent,
......@@ -211,7 +212,9 @@ export function ComposerEditor({
211212 },
212213 [],
213214 );
215 const { systemColorsActive } = useAppearancePreferences();
214216 const themeJson = JSON.stringify({
217 selection: systemColorsActive ? theme["--color-primary"] : null,
215218 text: theme["--color-foreground"],
216219 placeholder: theme["--color-placeholder"],
217220 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
77Mobile has its own themes and text, code, and terminal preferences. It does not follow environment
88themes 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
1014## Motion
1115
1216The main sidebar, right panel, and terminal drawer open and close immediately by default. Move the
pnpm-lock.yaml+8
......@@ -247,6 +247,9 @@ importers:
247247 '@legendapp/list':
248248 specifier: 'catalog:'
249249 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
250253 '@noble/curves':
251254 specifier: 'catalog:'
252255 version: 1.9.1
......@@ -3412,6 +3415,9 @@ packages:
34123415 resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
34133416 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
34153421 '@modelcontextprotocol/sdk@1.29.0':
34163422 resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
34173423 engines: {node: '>=18'}
......@@ -14208,6 +14214,8 @@ snapshots:
1420814214 transitivePeerDependencies:
1420914215 - supports-color
1421014216
14217 '@material/material-color-utilities@0.3.0': {}
14218
1421114219 '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)':
1421214220 dependencies:
1421314221 '@hono/node-server': 1.19.14(hono@4.12.27)