diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 3010b5240997c2049690f769d8f991d24f5a996d..48423ef48ff3be5f43e0ce0406bd67832cc6e79a 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -7,11 +7,13 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface +import android.os.Build import android.text.Editable import android.text.InputType import android.text.Spanned import android.text.TextWatcher import android.text.style.ReplacementSpan +import android.util.TypedValue import android.view.Gravity import android.view.ViewGroup import android.view.inputmethod.InputMethodManager @@ -27,6 +29,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( appContext ) { private val editor = SelectionAwareEditText(context) + private val defaultHighlightColor = editor.highlightColor + private val defaultSelectionColor = context.resolveThemeColor( + android.R.attr.colorAccent, + editor.currentTextColor, + ) private val onComposerChange by EventDispatcher() private val onComposerSelectionChange by EventDispatcher() private val onComposerFocus by EventDispatcher() @@ -152,6 +159,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( val theme = JSONObject(themeJson) editor.setTextColor(parseColor(theme.optString("text"), Color.BLACK)) editor.setHintTextColor(parseColor(theme.optString("placeholder"), Color.GRAY)) + if (theme.isNull("selection")) { + resetSelectionTheme() + } else { + applySelectionTheme(parseColor(theme.optString("selection"), editor.currentTextColor)) + } chipTheme = ComposerChipTheme( chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground), chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder), @@ -277,6 +289,21 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f) } + private fun applySelectionTheme(color: Int) { + editor.highlightColor = color.withAlpha(0x52) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + + editor.textCursorDrawable = editor.textCursorDrawable?.mutate()?.apply { setTint(color) } + editor.textSelectHandle?.mutate()?.setTint(color) + editor.textSelectHandleLeft?.mutate()?.setTint(color) + editor.textSelectHandleRight?.mutate()?.setTint(color) + } + + private fun resetSelectionTheme() { + applySelectionTheme(defaultSelectionColor) + editor.highlightColor = defaultHighlightColor + } + private fun currentSelectionPayload(): Map = mapOf( "start" to editor.selectionStart.coerceAtLeast(0), @@ -335,12 +362,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private fun parseColor(value: String, fallback: Int): Int = try { - Color.parseColor(value) + val androidColor = if (value.length == 9 && value.startsWith("#")) { + "#${value.takeLast(2)}${value.substring(1, 7)}" + } else { + value + } + Color.parseColor(androidColor) } catch (_: Exception) { fallback } } +private fun Int.withAlpha(alpha: Int): Int = (this and 0x00FFFFFF) or (alpha shl 24) + +private fun Context.resolveThemeColor(attribute: Int, fallback: Int): Int { + val value = TypedValue() + return if (theme.resolveAttribute(attribute, value, true)) value.data else fallback +} + private data class ComposerToken( val type: String, val source: String, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fbe2785e07bfe27f0c0f347c3a1ec9723523bafe..9a2925ccd9cf83f3a627e9306396e40fef387d79 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -51,6 +51,7 @@ "@expo/metro-runtime": "~57.0.14", "@expo/ui": "~57.0.14", "@legendapp/list": "catalog:", + "@material/material-color-utilities": "0.3.0", "@noble/curves": "catalog:", "@pierre/diffs": "catalog:", "@react-native-ai/apple": "0.12.0", diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index ffc4a71f7a1f10b16f3694b53e7d45956f4eb364..bca293b3e7a60a36a6cab199ec91f40f93b627df 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -66,7 +66,7 @@ function filesPatch(paths: ReadonlyArray) { } function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) { - return themeId === DEFAULT_MOBILE_THEME_ID + return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" ? readDefaultMobileThemeVariables(appearance) : getMobileThemeVariables(themeId, appearance); } diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index f161e654788be5c5a5a13864d4648cded7ba3907..31b29a1ddaca2d09f074808d62cd36f019119bef 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -3,17 +3,19 @@ import { startTransition, use, useCallback, + useEffect, + useState, useLayoutEffect, useMemo, useRef, type ReactNode, } from "react"; -import { Appearance, useColorScheme } from "react-native"; +import { AppState, Appearance, useColorScheme } from "react-native"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { ScopedTheme, Uniwind } from "uniwind"; +import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind"; import { resolveAppearance, @@ -22,6 +24,10 @@ import { } from "../../../lib/appearancePreferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; +import { isSystemColorsAvailable, readSystemColorPalettes } from "../../../lib/materialYouPalette"; +import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme"; +import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables"; +import type { MobileThemeVariables } from "../../../lib/mobileTheme"; import { createMobileThemePairPatch, createMobileThemeSelectionPatch, @@ -45,6 +51,13 @@ interface AppearancePreferencesContextValue { readonly themeIds: MobileThemeIds; readonly themeMode: MobileThemeMode; readonly themeAppearance: MobileThemeAppearance; + readonly systemColorsAvailable: boolean; + readonly systemColorsActive: boolean; + readonly themeVariables: MobileThemeVariables; + readonly themeVariablesByAppearance: Readonly< + Record + >; + readonly systemColorPalettes: ReturnType; readonly isReady: boolean; readonly setThemeIdForAppearance: ( appearance: MobileThemeAppearance, @@ -81,6 +94,39 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [resolvedThemeIds.dark, resolvedThemeIds.light], ); const themeId = themeIds[themeAppearance]; + const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; + const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); + useEffect(() => { + if (!isSystemColorsAvailable) return; + const refresh = () => { + const next = readSystemColorPalettes(); + setSystemColorPalettes((previous) => + JSON.stringify(previous) === JSON.stringify(next) ? previous : next, + ); + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") refresh(); + }); + const focusSubscription = AppState.addEventListener("focus", refresh); + return () => { + subscription.remove(); + focusSubscription.remove(); + }; + }, []); + const themeVariablesByAppearance = useMemo(() => { + const resolve = (appearance: MobileThemeAppearance) => { + const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance); + return themeIds[appearance] === "material-you" && systemColorPalettes + ? materialYouPaletteToMobileThemeVariables( + systemColorPalettes[appearance], + appearance, + base, + ) + : base; + }; + return { light: resolve("light"), dark: resolve("dark") }; + }, [themeIds, systemColorPalettes]); + const themeVariables = themeVariablesByAppearance[themeAppearance]; const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; const appearance = useMemo( @@ -233,6 +279,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeIds, themeMode, themeAppearance, + systemColorsAvailable: isSystemColorsAvailable, + systemColorsActive, + themeVariables, + themeVariablesByAppearance, + systemColorPalettes, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, @@ -248,6 +299,10 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeIds, themeMode, themeAppearance, + systemColorsActive, + themeVariables, + themeVariablesByAppearance, + systemColorPalettes, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, @@ -261,7 +316,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN return ( - {props.children} + + {props.children} + ); } diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index 2257115828b65418d5955fc7bebdb340b9bd44e0..788d04b047bac4183f6b79af01c893e4432529de 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -1,7 +1,7 @@ import { memo, useId } from "react"; import { Pressable, View } from "react-native"; import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; -import { ScopedTheme } from "uniwind"; +import { ScopedTheme, ScopedVariables } from "uniwind"; import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; @@ -38,7 +38,12 @@ const PreviewOrb = memo(function PreviewOrb(props: { const idPrefix = useId().replaceAll(":", ""); const accentGradientId = `${idPrefix}-accent-glow`; const actionGradientId = `${idPrefix}-action-glow`; - const colors = getMobileThemePreviewColors(props.themeId, props.appearance); + const { systemColorPalettes } = useAppearancePreferences(); + const palette = systemColorPalettes?.[props.appearance]; + const colors = + props.themeId === "material-you" && palette + ? { canvas: palette.surface, accent: palette.primary, messageAction: palette.tertiary } + : getMobileThemePreviewColors(props.themeId, props.appearance); const spec = THEME_PREVIEW_RENDER_SPECS[props.appearance]; const accentRadius = Math.hypot( Math.max(spec.accent.center[0], 1 - spec.accent.center[0]), @@ -204,15 +209,20 @@ function PreviewPane(props: { readonly compact?: boolean }) { } function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) { + const { themeVariablesByAppearance } = useAppearancePreferences(); if (props.mode === "system") { return ( - + + + - + + + @@ -224,7 +234,9 @@ function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: - + + + @@ -278,6 +290,7 @@ export function ThemeAppearanceSection() { setThemeMode, themeIds, themeMode, + systemColorsAvailable, } = useAppearancePreferences(); return ( @@ -302,7 +315,9 @@ export function ThemeAppearanceSection() { Themes - {MOBILE_THEME_OPTIONS.map((theme) => ( + {MOBILE_THEME_OPTIONS.filter( + (theme) => theme.id !== "material-you" || systemColorsAvailable, + ).map((theme) => ( candidate.id === themeId) ?? BUILT_IN_THEMES[0]; const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors; diff --git a/apps/mobile/src/lib/materialYouPalette.android.ts b/apps/mobile/src/lib/materialYouPalette.android.ts new file mode 100644 index 0000000000000000000000000000000000000000..c88dbc3747f7bf4d265ff84bd182e47d7148574f --- /dev/null +++ b/apps/mobile/src/lib/materialYouPalette.android.ts @@ -0,0 +1,14 @@ +import { getMaterialColors, isDynamicColorAvailable } from "@expo/ui/jetpack-compose"; + +import type { MaterialYouPalettes } from "./materialYouPalette"; + +export const isSystemColorsAvailable = isDynamicColorAvailable; + +export function readSystemColorPalettes(): MaterialYouPalettes | null { + if (!isDynamicColorAvailable) return null; + + return { + light: getMaterialColors({ scheme: "light" }), + dark: getMaterialColors({ scheme: "dark" }), + }; +} diff --git a/apps/mobile/src/lib/materialYouPalette.ts b/apps/mobile/src/lib/materialYouPalette.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1e52c8fa794662d639c94f05efb9599a9a4789e --- /dev/null +++ b/apps/mobile/src/lib/materialYouPalette.ts @@ -0,0 +1,36 @@ +export interface MaterialYouPalette { + readonly primary: string; + readonly onPrimary: string; + readonly primaryContainer: string; + readonly onPrimaryContainer: string; + readonly inversePrimary: string; + readonly secondaryContainer: string; + readonly onSecondaryContainer: string; + readonly tertiary: string; + readonly tertiaryContainer: string; + readonly onTertiaryContainer: string; + readonly surface: string; + readonly onSurface: string; + readonly onSurfaceVariant: string; + readonly surfaceContainer: string; + readonly surfaceContainerHigh: string; + readonly surfaceContainerHighest: string; + readonly surfaceContainerLow: string; + readonly surfaceContainerLowest: string; + readonly errorContainer: string; + readonly onErrorContainer: string; + readonly outline: string; + readonly outlineVariant: string; + readonly scrim: string; +} + +export interface MaterialYouPalettes { + readonly light: MaterialYouPalette; + readonly dark: MaterialYouPalette; +} + +export const isSystemColorsAvailable = false; + +export function readSystemColorPalettes(): MaterialYouPalettes | null { + return null; +} diff --git a/apps/mobile/src/lib/materialYouTheme.test.ts b/apps/mobile/src/lib/materialYouTheme.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d4676c06e768cea86890b4f98e650ec9d832b1e --- /dev/null +++ b/apps/mobile/src/lib/materialYouTheme.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; +import type { MaterialYouPalette } from "./materialYouPalette"; +import { materialYouPaletteToMobileThemeVariables } from "./materialYouTheme"; + +const palette: MaterialYouPalette = { + primary: "#6750A4FF", + onPrimary: "#FFFFFFFF", + primaryContainer: "#EADDFFFF", + onPrimaryContainer: "#21005DFF", + inversePrimary: "#D0BCFFFF", + secondaryContainer: "#E8DEF8FF", + onSecondaryContainer: "#1D192BFF", + tertiary: "#7D5260FF", + tertiaryContainer: "#FFD8E4FF", + onTertiaryContainer: "#31111DFF", + surface: "#FFFBFEFF", + onSurface: "#1C1B1FFF", + onSurfaceVariant: "#49454FFF", + surfaceContainer: "#F3EDF7FF", + surfaceContainerHigh: "#ECE6F0FF", + surfaceContainerHighest: "#E6E0E9FF", + surfaceContainerLow: "#F7F2FAFF", + surfaceContainerLowest: "#FFFFFFFF", + errorContainer: "#F9DEDCFF", + onErrorContainer: "#410E0BFF", + outline: "#79747EFF", + outlineVariant: "#CAC4D0FF", + scrim: "#000000FF", +}; + +describe("Material You system colors", () => { + it("overrides the selected theme without mutating its base variables", () => { + const base = getMobileThemeRuntimeVariables("t3-code", "dark"); + const snapshot = { ...base }; + + const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base); + + expect(base).toEqual(snapshot); + expect(variables["--color-screen"]).toBe(palette.surface); + expect(variables["--color-header"]).toBe(palette.surfaceContainerHigh); + expect(variables["--color-primary"]).toBe(palette.primary); + expect(variables["--color-placeholder"]).toBe("#1C1B1F9E"); + }); + + it("uses the Messages-style RCS tones for sent messages", () => { + const base = getMobileThemeRuntimeVariables("t3-code", "dark"); + const dark = materialYouPaletteToMobileThemeVariables( + { ...palette, inversePrimary: "#A31D8DFF" }, + "dark", + base, + ); + const light = materialYouPaletteToMobileThemeVariables( + { ...palette, inversePrimary: "#A31D8DFF" }, + "light", + getMobileThemeRuntimeVariables("t3-code", "light"), + ); + + expect(dark["--color-user-bubble"]).toBe("#850073FF"); + expect(dark["--color-user-bubble-foreground"]).toBe("#FFD7EFFF"); + expect(light["--color-user-bubble"]).toBe(palette.primary); + expect(light["--color-user-bubble-foreground"]).toBe(palette.onPrimary); + }); +}); diff --git a/apps/mobile/src/lib/materialYouTheme.ts b/apps/mobile/src/lib/materialYouTheme.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4542743ce0dd4f530cb0176f8e52b5c1fe32ce5 --- /dev/null +++ b/apps/mobile/src/lib/materialYouTheme.ts @@ -0,0 +1,101 @@ +import { Hct, argbFromHex, hexFromArgb } from "@material/material-color-utilities"; + +import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme"; +import type { MaterialYouPalette } from "./materialYouPalette"; + +function withAlpha(color: string, alpha: number): string { + const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); + if (!match?.[1]) return color; + const alphaByte = Math.round(Math.min(1, Math.max(0, alpha)) * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `#${match[1]}${alphaByte}`; +} + +function materialTone(color: string, tone: number): string { + const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); + if (!match?.[1]) return color; + + const source = Hct.fromInt(argbFromHex(match[1])); + return `${hexFromArgb(Hct.from(source.hue, source.chroma, tone).toInt()).toUpperCase()}FF`; +} + +export function materialYouPaletteToMobileThemeVariables( + palette: MaterialYouPalette, + appearance: MobileThemeAppearance, + base: MobileThemeVariables, +): MobileThemeVariables { + const dark = appearance === "dark"; + const userBubble = dark ? materialTone(palette.inversePrimary, 30) : palette.primary; + const userBubbleForeground = dark ? materialTone(palette.inversePrimary, 90) : palette.onPrimary; + + return { + ...base, + "--color-screen": palette.surface, + "--color-sheet": withAlpha(palette.surfaceContainerLow, 0.98), + "--color-sheet-solid": palette.surfaceContainerLow, + "--color-card": palette.surfaceContainer, + "--color-card-alt": palette.surfaceContainerHigh, + "--color-card-translucent": withAlpha(palette.surfaceContainer, 0.8), + "--color-foreground": palette.onSurface, + "--color-foreground-secondary": palette.onSurfaceVariant, + "--color-foreground-muted": palette.onSurfaceVariant, + "--color-foreground-tertiary": withAlpha(palette.onSurfaceVariant, 0.58), + "--color-border": withAlpha(palette.outlineVariant, dark ? 0.5 : 0.65), + "--color-border-subtle": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), + "--color-separator": withAlpha(palette.outlineVariant, dark ? 0.24 : 0.34), + "--color-subtle": withAlpha(palette.onSurface, 0.05), + "--color-subtle-strong": withAlpha(palette.onSurface, 0.1), + "--color-inline-skill-background": withAlpha(palette.tertiaryContainer, 0.52), + "--color-inline-skill-border": withAlpha(palette.tertiary, 0.38), + "--color-inline-skill-foreground": palette.onTertiaryContainer, + "--color-primary": palette.primary, + "--color-primary-foreground": palette.onPrimary, + "--color-primary-shadow": withAlpha(palette.scrim, dark ? 0.22 : 0.18), + "--color-secondary": palette.secondaryContainer, + "--color-secondary-foreground": palette.onSecondaryContainer, + "--color-secondary-border": withAlpha(palette.outlineVariant, dark ? 0.42 : 0.56), + "--color-switch-active-track": palette.primary, + "--color-switch-active-thumb": palette.onPrimary, + "--color-switch-inactive-track": palette.surfaceContainerHighest, + "--color-switch-inactive-thumb": palette.outline, + "--color-danger": palette.errorContainer, + "--color-danger-border": withAlpha(palette.onErrorContainer, dark ? 0.42 : 0.5), + "--color-danger-foreground": palette.onErrorContainer, + "--color-input": palette.surfaceContainerLowest, + "--color-input-border": withAlpha(palette.outline, dark ? 0.42 : 0.5), + "--color-sidebar-search": palette.surfaceContainerLowest, + "--color-placeholder": withAlpha(palette.onSurface, 0.62), + "--color-icon": palette.onSurface, + "--color-icon-muted": palette.onSurfaceVariant, + "--color-icon-subtle": withAlpha(palette.onSurfaceVariant, 0.68), + "--color-header": palette.surfaceContainerHigh, + "--color-header-border": withAlpha(palette.outlineVariant, dark ? 0.36 : 0.48), + "--color-glass-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.78 : 0.72), + "--color-glass-tint": withAlpha(palette.primaryContainer, dark ? 0.24 : 0.18), + "--color-status-bar": palette.surfaceContainerLow, + "--color-md-body": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), + "--color-md-strong": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), + "--color-md-link": palette.primary, + "--color-md-blockquote-border": withAlpha(palette.primary, dark ? 0.34 : 0.26), + "--color-md-blockquote-bg": withAlpha(palette.primaryContainer, dark ? 0.14 : 0.2), + "--color-md-code-bg": withAlpha(palette.onSurface, dark ? 0.08 : 0.06), + "--color-md-code-text": palette.onSurface, + "--color-md-user-code-bg": withAlpha(userBubbleForeground, dark ? 0.18 : 0.22), + "--color-md-user-code-text": userBubbleForeground, + "--color-md-user-fence-bg": withAlpha(userBubbleForeground, dark ? 0.14 : 0.18), + "--color-md-user-fence-text": userBubbleForeground, + "--color-md-hr": withAlpha(palette.outlineVariant, dark ? 0.48 : 0.6), + "--color-user-bubble": userBubble, + "--color-user-bubble-foreground": userBubbleForeground, + "--color-user-bubble-foreground-muted": withAlpha(userBubbleForeground, 0.78), + "--color-user-bubble-skill-foreground": userBubbleForeground, + "--color-backdrop": withAlpha(palette.scrim, dark ? 0.48 : 0.22), + "--color-drawer": palette.surface, + "--color-drawer-shadow": withAlpha(palette.scrim, dark ? 0.32 : 0.12), + "--color-dot-separator": withAlpha(palette.onSurface, 0.2), + "--color-wordmark": palette.onSurface, + "--color-chevron": withAlpha(palette.onSurface, 0.2), + }; +} diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 10ef1b58edeccd25be9407ebc7b1535f098d7d36..d3b8e164be327803e485640a77a351aa8c945ab6 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -14,8 +14,8 @@ import { } from "@t3tools/shared/themePreview"; export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID; -export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS; -export type MobileThemeId = SharedMobileThemeId; +export const MOBILE_THEME_IDS = [...SHARED_MOBILE_THEME_IDS, "material-you"] as const; +export type MobileThemeId = SharedMobileThemeId | "material-you"; export type MobileThemeAppearance = ThemeAppearance; export type MobileThemeMode = MobileThemeAppearance | "system"; export type MobileThemeIds = Readonly>; @@ -25,6 +25,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ readonly label: string; }> = [ { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" }, + { id: "material-you", label: "Material You" }, ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), ]; @@ -306,7 +307,8 @@ export function getMobileThemePreviewColors( themeId: MobileThemeId, appearance: MobileThemeAppearance, ): ThemePreviewColors { - if (themeId === DEFAULT_MOBILE_THEME_ID) return STANDARD_THEME_PREVIEW_COLORS[appearance]; + if (themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you") + return STANDARD_THEME_PREVIEW_COLORS[appearance]; const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; return { diff --git a/apps/mobile/src/lib/mobileThemeRuntime.ts b/apps/mobile/src/lib/mobileThemeRuntime.ts index 0c30de6bd46b7f9e86ddb0465acb51dc144a1bf2..a777b2929fcf6fb626a0abf135fe8cd0bfa7e4f7 100644 --- a/apps/mobile/src/lib/mobileThemeRuntime.ts +++ b/apps/mobile/src/lib/mobileThemeRuntime.ts @@ -42,7 +42,9 @@ export function getMobileUniwindThemeName( themeId: MobileThemeId, appearance: MobileThemeAppearance, ): MobileUniwindThemeName { - return themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`; + return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" + ? appearance + : `${themeId}-${appearance}`; } /** diff --git a/apps/mobile/src/lib/mobileThemeVariables.ts b/apps/mobile/src/lib/mobileThemeVariables.ts index 79a479b4fe61652cbdb359459f5c6ab1d43f0f7e..30c38e6bf42506985785933e130ebc9bf45e7f23 100644 --- a/apps/mobile/src/lib/mobileThemeVariables.ts +++ b/apps/mobile/src/lib/mobileThemeVariables.ts @@ -21,7 +21,7 @@ export function getMobileThemeRuntimeVariables( themeId: MobileThemeId, appearance: MobileThemeAppearance, ): MobileThemeVariables { - return themeId === DEFAULT_MOBILE_THEME_ID + return themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you" ? defaults[appearance] : getMobileThemeVariables(themeId, appearance); } diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 15e385067dcd31a6b4832f476e0cae69c27052d2..52202bdc5e7b96282fd31c46cca734aca2afa400 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -196,6 +196,14 @@ describe("mobile connection storage", () => { }); }); + it("persists Material You independently for each appearance", async () => { + const themes = { lightThemeId: "material-you", darkThemeId: "ocean" } as const; + await savePreferencesPatch(themes); + await expect(loadPreferences()).resolves.toEqual(themes); + await savePreferencesPatch({ lightThemeId: "t3-chat" }); + await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); + }); + it("drops the removed theme transition preference", async () => { mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10); diff --git a/apps/mobile/src/lib/useUniwindTheme.ts b/apps/mobile/src/lib/useUniwindTheme.ts index 06c50c859ae964eb3ead632c1cdb49fdcdcc03ef..b76dfca96b351ea6db5862f7a0e8ff6f24d4d189 100644 --- a/apps/mobile/src/lib/useUniwindTheme.ts +++ b/apps/mobile/src/lib/useUniwindTheme.ts @@ -1,8 +1,5 @@ -import { useMemo } from "react"; - import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import type { MobileThemeVariables } from "./mobileTheme"; -import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; /** * Complete JS palette for native and third-party APIs that cannot consume a @@ -13,9 +10,5 @@ import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; * ScopedTheme instead of subscribing every consumer to CSS-variable updates. */ export function useUniwindTheme(): MobileThemeVariables { - const { themeAppearance, themeId } = useAppearancePreferences(); - return useMemo( - () => getMobileThemeRuntimeVariables(themeId, themeAppearance), - [themeAppearance, themeId], - ); + return useAppearancePreferences().themeVariables; } diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 1a488d34f084b4c197f17b50b7d58070ce1cf2b1..129e1af2a92bc2f43afdd610c17c8f6392ba437c 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -18,6 +18,7 @@ import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { acknowledgeComposerNativeEvent, @@ -211,7 +212,9 @@ export function ComposerEditor({ }, [], ); + const { systemColorsActive } = useAppearancePreferences(); const themeJson = JSON.stringify({ + selection: systemColorsActive ? theme["--color-primary"] : null, text: theme["--color-foreground"], placeholder: theme["--color-placeholder"], chipBackground: theme["--color-subtle"], diff --git a/docs/user/appearance.md b/docs/user/appearance.md index 0300b8fd99465c2cc78c1a52358c135276809104..b37c91759afd6d050cbf592ddeb095ed9033bb7f 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -7,6 +7,10 @@ within each theme. Appearance preferences are saved separately on each device or Mobile has its own themes and text, code, and terminal preferences. It does not follow environment themes or defaults. +On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from +your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You +can be selected separately for light and dark appearances. + ## Motion The main sidebar, right panel, and terminal drawer open and close immediately by default. Move the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b449b48cc4ffd7c56ff34ae1e8c5526fd6c1c550..96a35631c361a2d96f29c282219a7f0656a25b4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: '@legendapp/list': specifier: 'catalog:' 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) + '@material/material-color-utilities': + specifier: 0.3.0 + version: 0.3.0 '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -3412,6 +3415,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@material/material-color-utilities@0.3.0': + resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -14208,6 +14214,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@material/material-color-utilities@0.3.0': {} + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.27)