From 3da778b084d28274e8f337c49ca8bbc0bf9f20f1 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 20 Aug 2026 17:39:32 -0700 Subject: [PATCH 1/2] feat(mobile): add Android system colors --- .../t3composereditor/T3ComposerEditorView.kt | 41 ++++++- apps/mobile/package.json | 1 + .../AppearancePreferencesProvider.tsx | 87 +++++++++++++-- .../sections/ThemeAppearanceSection.tsx | 24 ++++- .../src/lib/materialYouPalette.android.ts | 14 +++ apps/mobile/src/lib/materialYouPalette.ts | 36 +++++++ apps/mobile/src/lib/materialYouTheme.test.ts | 65 +++++++++++ apps/mobile/src/lib/materialYouTheme.ts | 101 ++++++++++++++++++ .../src/native/T3ComposerEditor.native.tsx | 4 + .../src/persistence/mobile-preferences.ts | 5 + pnpm-lock.yaml | 8 ++ 11 files changed, 377 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/lib/materialYouPalette.android.ts create mode 100644 apps/mobile/src/lib/materialYouPalette.ts create mode 100644 apps/mobile/src/lib/materialYouTheme.test.ts create mode 100644 apps/mobile/src/lib/materialYouTheme.ts 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 3010b5240997..48423ef48ff3 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 de53a37c995b..3fc4a4dad15d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -50,6 +50,7 @@ "@expo/metro-runtime": "~56.0.15", "@expo/ui": "~56.0.18", "@legendapp/list": "catalog:", + "@material/material-color-utilities": "0.3.0", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 96a01c051126..f41a78da2149 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,5 +1,14 @@ -import { createContext, use, useCallback, useLayoutEffect, useMemo, type ReactNode } from "react"; -import { useColorScheme } from "react-native"; +import { + createContext, + use, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { AppState, Platform, useColorScheme } from "react-native"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -14,6 +23,12 @@ import { } from "../../../lib/appearancePreferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; +import { + isSystemColorsAvailable, + readSystemColorPalettes, + type MaterialYouPalettes, +} from "../../../lib/materialYouPalette"; +import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme"; import { createMobileThemePairPatch, createMobileThemeSelectionPatch, @@ -34,6 +49,9 @@ interface AppearancePreferencesContextValue { readonly themeIds: MobileThemeIds; readonly themeMode: MobileThemeMode; readonly themeAppearance: MobileThemeAppearance; + readonly systemColorsAvailable: boolean; + readonly systemColorsEnabled: boolean; + readonly systemColorsActive: boolean; readonly isReady: boolean; readonly setThemeIdForAppearance: ( appearance: MobileThemeAppearance, @@ -41,6 +59,7 @@ interface AppearancePreferencesContextValue { ) => void; readonly setThemeIdForBothAppearances: (value: MobileThemeId) => void; readonly setThemeMode: (value: MobileThemeMode) => void; + readonly setSystemColorsEnabled: (value: boolean) => void; readonly setBaseFontSize: (value: number) => void; /** Pass null to clear the override and follow the base font size. */ readonly setTerminalFontSize: (value: number | null) => void; @@ -55,21 +74,40 @@ const AppearancePreferencesContext = createContext (systemColorsActive ? readSystemColorPalettes() : null), + [systemColorsActive, systemColorsRefreshKey], + ); + + useEffect(() => { + if (!systemColorsActive) return; + const refreshPalette = () => setSystemColorsRefreshKey((key) => key + 1); + const appStateSubscription = AppState.addEventListener("change", (state) => { + if (state === "active") refreshPalette(); + }); + const focusSubscription = + Platform.OS === "android" ? AppState.addEventListener("focus", refreshPalette) : null; + return () => { + appStateSubscription.remove(); + focusSubscription?.remove(); + }; + }, [systemColorsActive]); useLayoutEffect(() => { - applyAppearanceVariables(preferences.baseFontSize, themeIds); + applyAppearanceVariables(preferences.baseFontSize, themeIds, systemColorPalettes); Uniwind.setTheme(themeMode); cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); - }, [preferences, themeIds, themeMode]); + }, [preferences, systemColorPalettes, themeIds, themeMode]); const updatePreferences = useCallback( (patch: Partial) => { @@ -131,6 +190,13 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [updatePreferences], ); + const setSystemColorsEnabled = useCallback( + (value: boolean) => { + updatePreferences({ systemColorsEnabled: value }); + }, + [updatePreferences], + ); + const setBaseFontSize = useCallback( (value: number) => { updatePreferences({ baseFontSize: value }); @@ -166,10 +232,14 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeIds, themeMode, themeAppearance, + systemColorsAvailable: isSystemColorsAvailable, + systemColorsEnabled, + systemColorsActive, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, setThemeMode, + setSystemColorsEnabled, setBaseFontSize, setTerminalFontSize, setCodeFontSize, @@ -181,10 +251,13 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeIds, themeMode, themeAppearance, + systemColorsEnabled, + systemColorsActive, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, setThemeMode, + setSystemColorsEnabled, setBaseFontSize, setTerminalFontSize, setCodeFontSize, diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index ab2a99313985..5c40d8bc31ea 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -1,5 +1,5 @@ import { memo, useId } from "react"; -import { Pressable, View } from "react-native"; +import { Platform, Pressable, View } from "react-native"; import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; @@ -17,6 +17,8 @@ import { type MobileThemeVariables, } from "../../../../lib/mobileTheme"; import { useThemeColor } from "../../../../lib/useThemeColor"; +import { SettingsSection } from "../../components/SettingsSection"; +import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; const APPEARANCE_MODES: ReadonlyArray<{ @@ -320,6 +322,9 @@ export function ThemeAppearanceSection() { setThemeIdForAppearance, setThemeIdForBothAppearances, setThemeMode, + setSystemColorsEnabled, + systemColorsAvailable, + systemColorsEnabled, themeIds, themeMode, } = useAppearancePreferences(); @@ -360,6 +365,23 @@ export function ThemeAppearanceSection() { ))} + + {Platform.OS === "android" ? ( + + + + ) : null} ); } diff --git a/apps/mobile/src/lib/materialYouPalette.android.ts b/apps/mobile/src/lib/materialYouPalette.android.ts new file mode 100644 index 000000000000..c88dbc3747f7 --- /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 000000000000..e1e52c8fa794 --- /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 000000000000..f2979fd4ebe6 --- /dev/null +++ b/apps/mobile/src/lib/materialYouTheme.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getMobileThemeVariables } from "./mobileTheme"; +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 = getMobileThemeVariables("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 = getMobileThemeVariables("t3-code", "dark"); + const dark = materialYouPaletteToMobileThemeVariables( + { ...palette, inversePrimary: "#A31D8DFF" }, + "dark", + base, + ); + const light = materialYouPaletteToMobileThemeVariables( + { ...palette, inversePrimary: "#A31D8DFF" }, + "light", + getMobileThemeVariables("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 000000000000..d4542743ce0d --- /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/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index ff177abf1642..142f7c1a9bc2 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -19,6 +19,7 @@ import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; import { useThemeColor } from "../lib/useThemeColor"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { acknowledgeComposerNativeEvent, assumeComposerControlledState, @@ -113,6 +114,8 @@ export function ComposerEditor({ const confirmedTokensRef = useRef(initialConfirmedTokens); const textColor = useThemeColor("--color-foreground"); const placeholderColor = useThemeColor("--color-placeholder"); + const selectionColor = useThemeColor("--color-primary"); + const { systemColorsActive } = useAppearancePreferences(); const chipBackground = useThemeColor("--color-subtle"); const chipBorder = useThemeColor("--color-border"); const chipText = useThemeColor("--color-foreground"); @@ -222,6 +225,7 @@ export function ComposerEditor({ const themeJson = JSON.stringify({ text: String(textColor), placeholder: String(placeholderColor), + selection: systemColorsActive ? String(selectionColor) : null, chipBackground: String(chipBackground), chipBorder: String(chipBorder), chipText: String(chipText), diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index dfaeab9cd6ba..48087b38d459 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -21,6 +21,7 @@ export interface Preferences { readonly lightThemeId?: MobileThemeId; readonly darkThemeId?: MobileThemeId; readonly themeMode?: MobileThemeMode; + readonly systemColorsEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; readonly markdownFontSize?: number; @@ -88,6 +89,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { lightThemeId?: MobileThemeId; darkThemeId?: MobileThemeId; themeMode?: MobileThemeMode; + systemColorsEnabled?: boolean; baseFontSize?: number; terminalFontSize?: number | null; markdownFontSize?: number; @@ -130,6 +132,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.themeMode = parsed.themeMode; } + if (typeof parsed.systemColorsEnabled === "boolean") { + preferences.systemColorsEnabled = parsed.systemColorsEnabled; + } if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { preferences.terminalFontSize = parsed.terminalFontSize; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f66f69b87f0..33f6d34fed1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,9 @@ importers: '@legendapp/list': specifier: 'catalog:' version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.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 @@ -3126,6 +3129,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'} @@ -13260,6 +13266,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) From c8c41db8c41c161cda4d16c98e92744ca06d4c70 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 20 Aug 2026 18:07:58 -0700 Subject: [PATCH 2/2] feat(mobile): add Material You style layout --- apps/mobile/global.css | 14 ++ .../src/components/AndroidScreenHeader.tsx | 2 + apps/mobile/src/components/ThemedSwitch.tsx | 61 +++++- .../features/files/ThreadFilesRouteScreen.tsx | 33 +++- apps/mobile/src/features/home/HomeHeader.tsx | 14 +- apps/mobile/src/features/home/HomeScreen.tsx | 176 ++++++++++-------- .../layout/AdaptiveWorkspaceLayout.tsx | 11 +- .../features/layout/WorkspaceEmptyDetail.tsx | 10 +- .../AppearancePreferencesProvider.tsx | 18 ++ .../sections/ThemeAppearanceSection.tsx | 11 ++ .../threads/GitActionProgressOverlay.tsx | 55 +++++- .../features/threads/NewTaskDraftScreen.tsx | 11 +- .../src/features/threads/ThreadComposer.tsx | 41 ++-- .../src/features/threads/ThreadFeed.tsx | 52 +++++- .../threads/ThreadNavigationSidebar.tsx | 104 ++++++++--- .../features/threads/ThreadRouteScreen.tsx | 23 ++- .../features/threads/ThreadSettingsSheet.tsx | 30 ++- .../features/threads/thread-list-items.tsx | 104 ++++++++--- .../features/threads/thread-list-v2-items.tsx | 83 +++++++-- .../features/threads/thread-search-match.tsx | 16 +- apps/mobile/src/lib/materialYouTheme.test.ts | 2 + apps/mobile/src/lib/materialYouTheme.ts | 7 + apps/mobile/src/lib/mobileDefaultTheme.ts | 14 ++ apps/mobile/src/lib/mobileTheme.test.ts | 8 +- apps/mobile/src/lib/mobileTheme.ts | 10 + .../src/persistence/mobile-preferences.ts | 5 + docs/user/mobile-appearance.md | 9 + 27 files changed, 722 insertions(+), 202 deletions(-) diff --git a/apps/mobile/global.css b/apps/mobile/global.css index a42afc74d92f..932aee1cfe75 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -18,6 +18,13 @@ --color-card: #ffffff; --color-card-alt: #f5f5f5; --color-card-translucent: rgba(255, 255, 255, 0.8); + --color-thread-canvas: #f5f5f5; + --color-thread-selected: #ffffff; + --color-thread-selected-foreground: #262626; + --color-thread-selected-foreground-muted: #737373; + --color-composer-panel: rgba(245, 245, 245, 0.88); + --color-composer-surface: rgba(255, 255, 255, 0.94); + --color-composer-border: rgba(0, 0, 0, 0.1); /* Text */ --color-foreground: #262626; @@ -117,6 +124,13 @@ --color-card: #171717; --color-card-alt: #1c1c1c; --color-card-translucent: rgba(17, 17, 17, 0.8); + --color-thread-canvas: #1c1c1c; + --color-thread-selected: #171717; + --color-thread-selected-foreground: #f5f5f5; + --color-thread-selected-foreground-muted: #8e8e93; + --color-composer-panel: rgba(28, 28, 28, 0.92); + --color-composer-surface: rgba(23, 23, 23, 0.9); + --color-composer-border: rgba(255, 255, 255, 0.08); /* Text */ --color-foreground: #f5f5f5; diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 7fe21fb44ff3..2f89d7082412 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -52,6 +52,7 @@ export function AndroidScreenHeader(props: { readonly trailing?: ReactNode; readonly onBack?: () => void; readonly embedded?: boolean; + readonly hideBottomBorder?: boolean; }) { const insets = useSafeAreaInsets(); const foregroundColor = useThemeColor("--color-foreground"); @@ -61,6 +62,7 @@ export function AndroidScreenHeader(props: { className="border-b border-header-border bg-header px-3 pb-2.5" style={{ paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), + borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index 270ee084e428..f784908ad258 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,20 +1,73 @@ -import { Platform, Switch, type SwitchProps } from "react-native"; +import { Platform, Pressable, Switch, View, type SwitchProps } from "react-native"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useThemeColor } from "../lib/useThemeColor"; +import { SymbolView } from "./AppSymbol"; export function ThemedSwitch(props: SwitchProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const activeTrack = String(useThemeColor("--color-switch-active-track")); const inactiveTrack = String(useThemeColor("--color-switch-inactive-track")); const activeThumb = String(useThemeColor("--color-switch-active-thumb")); const inactiveThumb = String(useThemeColor("--color-switch-inactive-thumb")); + const value = Boolean(props.value); + + if (Platform.OS === "android" && materialYouStyleLayoutActive) { + const track = value ? activeTrack : inactiveTrack; + const thumb = value ? activeThumb : inactiveThumb; + + return ( + props.onValueChange?.(!value)} + style={{ opacity: props.disabled ? 0.38 : 1 }} + testID={props.testID} + > + + + + + + + ); + } return ( ); diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 28356be18524..22f025546d19 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -244,9 +244,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { useAdaptiveWorkspaceLayout(); const [searchQuery, setSearchQuery] = useState(""); const isAndroid = Platform.OS === "android"; - const { themeAppearance: highlightTheme } = useAppearancePreferences(); + const { materialYouStyleLayoutActive, themeAppearance: highlightTheme } = + useAppearancePreferences(); const iconColor = String(useThemeColor("--color-icon-muted")); const sheetSurfaceColor = String(useThemeColor("--color-sheet-solid")); + const screenColor = String(useThemeColor("--color-screen")); const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); @@ -361,14 +363,16 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; - return ( + const content = ( <> {/* Static header config (glass preset and title) lives in Stack.tsx. The live sheet color stays dynamic here so the FlatList can remain the direct scene child for native scroll-edge sampling. */} 0 ? projectName : undefined, @@ -404,6 +408,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { title="Files" subtitle={projectName} onBack={handleReturnToThread} + hideBottomBorder={materialYouStyleLayoutActive} actions={[ { accessibilityLabel: "Refresh files", @@ -412,13 +417,23 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { }, ]} /> - + ); + + return materialYouStyleLayoutActive ? ( + + {content} + + ) : ( + content + ); } export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e7ce41cb43bd..225a708fae86 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -13,6 +13,7 @@ import { T3Wordmark } from "../../components/T3Wordmark"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../../lib/mobileBranding"; import { useThemeColor } from "../../lib/useThemeColor"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -67,6 +68,7 @@ function checkedMenuState(checked: boolean) { function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); @@ -201,7 +203,11 @@ function AndroidHomeHeader(props: HomeHeaderProps) { <> - + >(() => new Map()); @@ -1129,100 +1131,116 @@ export function HomeScreen(props: HomeScreenProps) { if (threadListV2Enabled) { return ( - + + + + 0 ? ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) + + + ) : null + } + ListEmptyComponent={v2ListEmpty} + style={{ flex: 1 }} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + showsVerticalScrollIndicator={false} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + scrollEventThrottle={16} + contentContainerStyle={{ + paddingBottom: + Platform.OS === "ios" + ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance + : Math.max(insets.bottom, 16) + 88, + }} + /> + + + + ); + } + + return ( + + + {/* Sticky headers are deliberately not wired up: LegendList's JS sticky + implementation mispositions pinned headers at mount under iOS + automatic content insets (headers render one nav-inset too low until + the first scroll event) and blanks non-pinned headers after + collapse/expand data changes. The flattened layout still exposes + `stickyHeaderIndices` if this gets revisited. */} - 0 ? ( - ({ opacity: pressed ? 0.6 : 1 })} - > - - Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) - - - ) : null - } - ListEmptyComponent={v2ListEmpty} + - ); - } - - return ( - - {/* Sticky headers are deliberately not wired up: LegendList's JS sticky - implementation mispositions pinned headers at mount under iOS - automatic content insets (headers render one nav-inset too low until - the first scroll event) and blanks non-pinned headers after - collapse/expand data changes. The flattened layout still exposes - `stickyHeaderIndices` if this gets revisited. */} - - - ); } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index e00433de0ed9..3de74e468ba4 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -46,6 +46,7 @@ import { useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; import { HomeListOptionsProvider } from "../home/home-list-options"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -218,6 +219,7 @@ function AdaptiveWorkspaceLayoutContent( }, ) { const projectGroupingMode = props.projectGroupingMode; + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -542,7 +544,14 @@ function AdaptiveWorkspaceLayoutContent( /> ) : null} - + void }) { const iconColor = useThemeColor("--color-icon-subtle"); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( - + Select a thread diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index f41a78da2149..5ab4f322c041 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -52,6 +52,8 @@ interface AppearancePreferencesContextValue { readonly systemColorsAvailable: boolean; readonly systemColorsEnabled: boolean; readonly systemColorsActive: boolean; + readonly materialYouStyleLayoutEnabled: boolean; + readonly materialYouStyleLayoutActive: boolean; readonly isReady: boolean; readonly setThemeIdForAppearance: ( appearance: MobileThemeAppearance, @@ -60,6 +62,7 @@ interface AppearancePreferencesContextValue { readonly setThemeIdForBothAppearances: (value: MobileThemeId) => void; readonly setThemeMode: (value: MobileThemeMode) => void; readonly setSystemColorsEnabled: (value: boolean) => void; + readonly setMaterialYouStyleLayoutEnabled: (value: boolean) => void; readonly setBaseFontSize: (value: number) => void; /** Pass null to clear the override and follow the base font size. */ readonly setTerminalFontSize: (value: number | null) => void; @@ -135,6 +138,8 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; const systemColorsEnabled = storedPreferences?.systemColorsEnabled ?? false; const systemColorsActive = systemColorsEnabled && isSystemColorsAvailable; + const materialYouStyleLayoutEnabled = storedPreferences?.materialYouStyleLayoutEnabled ?? false; + const materialYouStyleLayoutActive = Platform.OS === "android" && materialYouStyleLayoutEnabled; const systemColorPalettes = useMemo( () => (systemColorsActive ? readSystemColorPalettes() : null), [systemColorsActive, systemColorsRefreshKey], @@ -197,6 +202,13 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [updatePreferences], ); + const setMaterialYouStyleLayoutEnabled = useCallback( + (value: boolean) => { + updatePreferences({ materialYouStyleLayoutEnabled: value }); + }, + [updatePreferences], + ); + const setBaseFontSize = useCallback( (value: number) => { updatePreferences({ baseFontSize: value }); @@ -235,11 +247,14 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN systemColorsAvailable: isSystemColorsAvailable, systemColorsEnabled, systemColorsActive, + materialYouStyleLayoutEnabled, + materialYouStyleLayoutActive, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, setThemeMode, setSystemColorsEnabled, + setMaterialYouStyleLayoutEnabled, setBaseFontSize, setTerminalFontSize, setCodeFontSize, @@ -253,11 +268,14 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeAppearance, systemColorsEnabled, systemColorsActive, + materialYouStyleLayoutEnabled, + materialYouStyleLayoutActive, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, setThemeMode, setSystemColorsEnabled, + setMaterialYouStyleLayoutEnabled, setBaseFontSize, setTerminalFontSize, setCodeFontSize, diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index 5c40d8bc31ea..85c86fe29c03 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -323,8 +323,10 @@ export function ThemeAppearanceSection() { setThemeIdForBothAppearances, setThemeMode, setSystemColorsEnabled, + setMaterialYouStyleLayoutEnabled, systemColorsAvailable, systemColorsEnabled, + materialYouStyleLayoutEnabled, themeIds, themeMode, } = useAppearancePreferences(); @@ -380,6 +382,15 @@ export function ThemeAppearanceSection() { } value={systemColorsAvailable && systemColorsEnabled} /> + + ) : null} diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 8aeadc95cb6c..660f6c3d4b63 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -67,14 +67,26 @@ export function GitActionProgressOverlay(props: { function OverlayContent(props: { readonly progress: GitActionProgress }) { const { progress } = props; + const { materialYouStyleLayoutActive, themeAppearance } = useAppearancePreferences(); const iconColor = useThemeColor("--color-icon"); + const primaryColor = useThemeColor("--color-primary"); + const primaryForegroundColor = useThemeColor("--color-primary-foreground"); + const dangerColor = useThemeColor("--color-danger"); + const dangerForegroundColor = useThemeColor("--color-danger-foreground"); const glassBorder = useThemeColor("--color-header-border"); const glassTint = useThemeColor("--color-glass-tint"); - const { themeAppearance } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; const content = ( <> - + {progress.label ? ( @@ -132,13 +144,18 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { const bgClass = progress.phase === "error" - ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800" - : "bg-card border-border"; + ? materialYouStyleLayoutActive + ? "border-danger-border bg-danger" + : "border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/80" + : materialYouStyleLayoutActive + ? "border-header-border bg-header" + : "border-border bg-card"; + const shadowClass = materialYouStyleLayoutActive ? "" : "shadow-lg shadow-black/10"; return ( {content} @@ -148,23 +165,43 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { function OverlayIcon(props: { readonly phase: GitActionProgress["phase"]; readonly iconColor: ReturnType; + readonly materialYouStyleLayoutActive: boolean; + readonly primaryColor: ReturnType; + readonly primaryForegroundColor: ReturnType; + readonly dangerColor: ReturnType; + readonly dangerForegroundColor: ReturnType; }) { switch (props.phase) { case "running": return ; case "success": return ( - - + + ); case "error": return ( - + diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f5beb69c938..1cb73f539c3a 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -109,7 +109,7 @@ export function NewTaskDraftScreen(props: { reserveShare, } = useIncomingShare(); const insets = useSafeAreaInsets(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); + const { themeAppearance: colorScheme, materialYouStyleLayoutActive } = useAppearancePreferences(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); @@ -293,6 +293,7 @@ export function NewTaskDraftScreen(props: { const foregroundColor = useThemeColor("--color-foreground"); const sheetColor = String(useThemeColor("--color-sheet")); + const composerPanel = useThemeColor("--color-composer-panel"); const projectUnderlineColor = useThemeColor("--color-foreground-muted"); const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); @@ -955,7 +956,13 @@ export function NewTaskDraftScreen(props: { ); const composerDock = ( - + {workspaceControls} {/* The backdrop gradient lives on a plain View: Reanimated's Animated.View silently drops experimental_backgroundImage on Android, which left this strip fully transparent and the feed text legible through the composer. */} - + {materialYouStyleLayoutActive ? null : ( + + )} failedMarkdownFaviconHosts.has(props.host)); return ( { void tryOpenExternalUrl(props.href, "markdown-link"); }} @@ -409,7 +411,7 @@ function useReviewCommentColors(): ReviewCommentColors { } function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { - const { appearance, themeAppearance } = useAppearancePreferences(); + const { appearance, systemColorsActive, themeAppearance } = useAppearancePreferences(); const markdownFontSizes = useMemo( () => resolveMarkdownFontSizes(appearance.baseFontSize), [appearance.baseFontSize], @@ -428,6 +430,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe const markdownCodeText = String(useThemeColor("--color-md-code-text")); const markdownInlineCodeText = String(useThemeColor("--color-foreground-secondary")); const markdownHrColor = String(useThemeColor("--color-md-hr")); + const primaryColor = useThemeColor("--color-primary"); const markdownUserBodyColor = String(useThemeColor("--color-user-bubble-foreground")); const markdownUserCodeBg = String(useThemeColor("--color-md-user-code-bg")); const markdownUserCodeText = String(useThemeColor("--color-md-user-code-text")); @@ -442,6 +445,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe const boldFontFamily = useFontFamily("bold"); return useMemo(() => { + const selectionColor = systemColorsActive ? primaryColor : undefined; const baseTheme: PartialMarkdownTheme = { colors: { text: markdownBodyColor, @@ -453,9 +457,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe surfaceLight: markdownBlockquoteBg, accent: markdownLinkColor, tableBorder: markdownHrColor, - tableHeader: markdownBlockquoteBg, + tableHeader: markdownCodeBg, tableHeaderText: markdownStrongColor, - tableRowOdd: "transparent", + tableRowOdd: markdownBlockquoteBg, tableRowEven: "transparent", }, spacing: { @@ -528,7 +532,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe }; const createMarkdownRenderers = ( - inlineTextColor: string, + bodyTextColor: string, inlineCodeTextColor: string, blockBackgroundColor: string, blockTextColor: string, @@ -536,14 +540,33 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe preserveSoftBreaks: boolean, highlightCode: boolean, ): CustomRenderers => ({ + paragraph: ({ children }) => ( + + {children} + + ), + text: ({ node }) => ( + {node.content ?? ""} + ), link: ({ children, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { return ( onLinkPress(href)} - style={{ color: inlineTextColor }} + style={{ color: bodyTextColor }} > void): MarkdownStyleSe href={presentation.href} host={presentation.host} color={markdownLinkColor} + selectionColor={selectionColor} > {children} @@ -568,6 +592,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe return ( { @@ -594,10 +619,11 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe void): MarkdownStyleSe return ( void): MarkdownStyleSe }, ...(preserveSoftBreaks ? { - soft_break: () => {"\n"}, + soft_break: () => {"\n"}, } : {}), code_block: ({ content = "", language }) => ( @@ -660,6 +687,11 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe code: markdownUserCodeText, codeBackground: markdownUserCodeBg, border: markdownUserFenceBg, + tableBorder: markdownUserCodeBg, + tableHeader: markdownUserFenceBg, + tableHeaderText: markdownUserBodyColor, + tableRowOdd: markdownUserCodeBg, + tableRowEven: "transparent", }, }; const userStyles: NodeStyleOverrides = { @@ -700,7 +732,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe theme: userTheme, styles: userStyles, renderers: createMarkdownRenderers( - markdownUserCodeText, + markdownUserBodyColor, markdownUserInlineCodeText, markdownUserFenceBg, markdownUserFenceText, @@ -733,7 +765,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe theme: assistantTheme, styles: assistantStyles, renderers: createMarkdownRenderers( - markdownCodeText, + markdownBodyColor, markdownInlineCodeText, markdownCodeBg, markdownCodeText, @@ -785,7 +817,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe markdownUserInlineCodeText, nativeMarkdownTypography, onLinkPress, + primaryColor, regularFontFamily, + systemColorsActive, themeMode, userBubbleForegroundMuted, userBubbleSkillForeground, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 2e8186fa8e25..646fffdc3ba0 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -194,7 +194,7 @@ function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { const insets = useSafeAreaInsets(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); + const { themeAppearance: colorScheme, materialYouStyleLayoutActive } = useAppearancePreferences(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); @@ -773,10 +773,13 @@ function ThreadNavigationSidebarPane( ); const backgroundColor = useThemeColor("--color-drawer"); + const headerColor = useThemeColor("--color-header"); + const screenColor = useThemeColor("--color-screen"); const borderColor = useThemeColor("--color-border"); const mutedColor = useThemeColor("--color-foreground-muted"); const placeholderColor = useThemeColor("--color-placeholder"); - const headerFadeColor = String(backgroundColor); + const sidebarBackgroundColor = materialYouStyleLayoutActive ? headerColor : backgroundColor; + const headerFadeColor = String(sidebarBackgroundColor); const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); // The sticky header (title row, search field, optional connection status) @@ -927,7 +930,7 @@ function ThreadNavigationSidebarPane( ?.environmentLabel ?? null) : null } - pane="sidebar" + pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} showPendingDivider={item.showPendingDivider} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} @@ -968,7 +971,7 @@ function ThreadNavigationSidebarPane( }), )} searchQuery={props.searchQuery} - pane="sidebar" + pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} selected={ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey } @@ -1010,7 +1013,7 @@ function ThreadNavigationSidebarPane( count={item.count} expanded={item.expanded} onToggle={toggleSnoozedShelf} - pane="sidebar" + pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} /> ); case "v2-settled-shelf": @@ -1019,7 +1022,7 @@ function ThreadNavigationSidebarPane( count={item.count} expanded={item.expanded} onToggle={toggleSettledShelf} - pane="sidebar" + pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} /> ); case "v2-show-more": @@ -1039,7 +1042,7 @@ function ThreadNavigationSidebarPane( case "header": return ( - + @@ -1394,21 +1423,50 @@ function ThreadNavigationSidebarPane( - - + + + {materialYouStyleLayoutActive && props.searchQuery.length > 0 ? ( + props.onSearchQueryChange("")} + > + + + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index cad1cab8e602..223d7d091f8b 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -64,6 +64,8 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAdaptiveWorkspaceLayout, useAdaptiveWorkspacePaneRole, @@ -180,6 +182,8 @@ function ThreadRouteContent( readonly selectedThreadDetailState: ReturnType; }, ) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + const headerColor = useThemeColor("--color-header"); const { fileInspector, layout, @@ -765,7 +769,19 @@ function ThreadRouteContent( - + (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) : undefined, unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, + contentStyle: + Platform.OS === "android" && materialYouStyleLayoutActive + ? { backgroundColor: headerColor } + : undefined, }} /> @@ -857,6 +877,7 @@ function ThreadRouteContent( subtitle={headerSubtitle} onBack={layout.usesSplitView ? undefined : () => navigation.goBack()} actions={androidHeaderActions} + hideBottomBorder={materialYouStyleLayoutActive} /> ) : null} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index b47e41d3e01e..9ecd0c732ba6 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -48,6 +48,7 @@ import { } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useNewTaskFlow } from "./new-task-flow-provider"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, @@ -91,7 +92,11 @@ function ModelRow(props: { readonly isFirst: boolean; readonly isLast: boolean; }) { - const checkmarkColor = useThemeColor("--color-icon"); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + const iconColor = useThemeColor("--color-icon"); + const primaryColor = useThemeColor("--color-primary"); + const primaryForegroundColor = useThemeColor("--color-primary-foreground"); + const selectedMaterialRow = materialYouStyleLayoutActive && props.selected; return ( - + {props.option.label} {props.option.isDefault ? ( - - Default + + + Default + ) : null} {props.option.isLegacy ? ( @@ -122,7 +142,7 @@ function ModelRow(props: { diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 78e6e43c075d..6e5dbde92390 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -439,9 +439,10 @@ export const ThreadListRow = memo(function ThreadListRow(props: { >["simultaneousWithExternalGesture"]; }) { const { width: windowWidth } = useWindowDimensions(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); + const { themeAppearance: colorScheme, materialYouStyleLayoutActive } = useAppearancePreferences(); const compact = props.variant === "compact"; const selected = props.selected === true; + const visuallySelected = selected && (!compact || materialYouStyleLayoutActive); // Recycling-safe: resets when the list container is reused for another // thread, so a hover highlight can't leak across rows. const [hovered, setHovered] = useRecyclingState(false); @@ -453,6 +454,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const pressedBackgroundColor = useThemeColor("--color-subtle"); const selectedBackgroundColor = useThemeColor("--color-user-bubble"); const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); + const materialSelectedBackgroundColor = useThemeColor("--color-thread-selected"); + const materialSelectedForegroundColor = useThemeColor("--color-thread-selected-foreground"); const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = props; @@ -467,15 +470,25 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const backgroundColor = compact ? screenColor : drawerColor; - const effectivePressedBackground = selected - ? themeColorWithAlpha(String(selectedForegroundColor), 0.16) + const effectiveSelectedBackground = materialYouStyleLayoutActive + ? materialSelectedBackgroundColor + : selectedBackgroundColor; + const effectiveSelectedForeground = materialYouStyleLayoutActive + ? materialSelectedForegroundColor + : selectedForegroundColor; + const effectivePressedBackground = visuallySelected + ? themeColorWithAlpha(String(effectiveSelectedForeground), 0.16) : pressedBackgroundColor; const effectiveStatus = - selected && status + visuallySelected && status ? { ...status, - pillClassName: "bg-user-bubble-foreground/20", - textClassName: "text-user-bubble-foreground", + pillClassName: materialYouStyleLayoutActive + ? "bg-thread-selected-foreground/20" + : "bg-user-bubble-foreground/20", + textClassName: materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground", } : status; @@ -530,9 +543,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { @@ -545,14 +561,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {pr.label} @@ -568,12 +588,20 @@ export const ThreadListRow = memo(function ThreadListRow(props: { accessibilityHint="Swipe left for archive and delete actions" accessibilityLabel={threadAccessibilityLabel} accessibilityRole="button" - className="bg-screen" + className={materialYouStyleLayoutActive ? undefined : "bg-screen"} onPress={() => { close(); onSelectThread(thread); }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + style={({ pressed }) => ({ + backgroundColor: visuallySelected + ? effectiveSelectedBackground + : pressed + ? effectivePressedBackground + : backgroundColor, + borderRadius: materialYouStyleLayoutActive ? SIDEBAR_ROW_RADIUS : 0, + opacity: pressed ? 0.7 : 1, + })} > - + {thread.title} {statusPill} - {timestamp} + + {timestamp} + ) : null} {subtitleRow} @@ -629,8 +679,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { onSelectThread(thread); }} style={({ pressed }) => ({ - backgroundColor: selected - ? selectedBackgroundColor + backgroundColor: visuallySelected + ? effectiveSelectedBackground : pressed || hovered ? effectivePressedBackground : backgroundColor, @@ -647,7 +697,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { @@ -658,7 +712,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { @@ -670,7 +728,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null} {subtitleRow} @@ -682,7 +740,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { @@ -694,7 +703,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "text-xs tabular-nums", selected - ? "text-user-bubble-foreground" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground" : (statusLabel?.className ?? "text-foreground-tertiary"), )} > @@ -704,7 +715,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -725,7 +740,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "flex-1 text-xs", selected - ? "text-user-bubble-foreground-muted" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground-muted" + : "text-user-bubble-foreground-muted" : "text-red-600/80 dark:text-red-400/80", )} numberOfLines={1} @@ -739,7 +756,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -747,7 +768,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -759,7 +784,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {props.environmentLabel} @@ -772,7 +801,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {pr ? ( #{pr.label} @@ -799,16 +835,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSelectThread(thread); }} style={ - sidebarPane + sidebarPane || materialYouStyleLayoutActive ? ({ pressed }) => ({ backgroundColor: selected ? selectedBackgroundColor : pressed ? pressedBackgroundColor - : drawerColor, + : sidebarPane + ? drawerColor + : screenColor, borderRadius: SIDEBAR_V2_ROW_RADIUS, - paddingHorizontal: 12, - paddingVertical: 10, + ...(sidebarPane ? { paddingHorizontal: 12, paddingVertical: 10 } : null), }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) } @@ -820,7 +857,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { labels and text hierarchy carry state, an inset hairline separates rows. The opaque screen background stays so swipe actions reveal behind the row. */ - + {cardContent} {props.showTrailingDivider !== false ? ( @@ -834,19 +871,21 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityLabel={thread.title} accessibilityRole="button" accessibilityState={{ selected }} - className={sidebarPane ? undefined : "bg-screen"} + className={sidebarPane || materialYouStyleLayoutActive ? undefined : "bg-screen"} onPress={() => { close(); onSelectThread(thread); }} style={ - sidebarPane + sidebarPane || materialYouStyleLayoutActive ? ({ pressed }) => ({ backgroundColor: selected ? selectedBackgroundColor : pressed ? pressedBackgroundColor - : drawerColor, + : sidebarPane + ? drawerColor + : screenColor, borderRadius: SIDEBAR_V2_ROW_RADIUS, }) : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) @@ -874,7 +913,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -892,7 +935,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "text-sm tabular-nums", selected - ? "text-user-bubble-foreground-muted" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground-muted" + : "text-user-bubble-foreground-muted" : snoozedRow ? "text-blue-600 dark:text-blue-400" : "text-foreground-tertiary", diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index da80ca0766ae..8fb556808ce2 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -2,6 +2,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; function foldAsciiCase(value: string): string { return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); @@ -50,12 +51,17 @@ export function ThreadSearchMatchExcerpt(props: { readonly compact?: boolean; }) { const isUser = props.match.source === "user"; + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const parts = splitHighlightParts(props.match.snippet, props.query); return ( @@ -63,7 +69,9 @@ export function ThreadSearchMatchExcerpt(props: { className={cn( props.compact ? "text-sm font-t3-medium" : "text-xs font-t3-medium", props.selected - ? "text-user-bubble-foreground" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground" : isUser ? "text-blue-500 dark:text-blue-400" : "text-emerald-600 dark:text-emerald-400", @@ -77,7 +85,9 @@ export function ThreadSearchMatchExcerpt(props: { props.compact ? "text-sm" : "text-xs", part.highlighted && "font-t3-bold", props.selected - ? "text-user-bubble-foreground" + ? materialYouStyleLayoutActive + ? "text-thread-selected-foreground" + : "text-user-bubble-foreground" : part.highlighted ? "text-foreground" : "text-foreground-muted", diff --git a/apps/mobile/src/lib/materialYouTheme.test.ts b/apps/mobile/src/lib/materialYouTheme.test.ts index f2979fd4ebe6..f981bd1822cf 100644 --- a/apps/mobile/src/lib/materialYouTheme.test.ts +++ b/apps/mobile/src/lib/materialYouTheme.test.ts @@ -39,6 +39,8 @@ describe("Material You system colors", () => { expect(base).toEqual(snapshot); expect(variables["--color-screen"]).toBe(palette.surface); + expect(variables["--color-thread-canvas"]).toBe(palette.surfaceContainerLow); + expect(variables["--color-thread-selected"]).toBe(palette.surfaceContainer); expect(variables["--color-header"]).toBe(palette.surfaceContainerHigh); expect(variables["--color-primary"]).toBe(palette.primary); expect(variables["--color-placeholder"]).toBe("#1C1B1F9E"); diff --git a/apps/mobile/src/lib/materialYouTheme.ts b/apps/mobile/src/lib/materialYouTheme.ts index d4542743ce0d..ce16b23c89ec 100644 --- a/apps/mobile/src/lib/materialYouTheme.ts +++ b/apps/mobile/src/lib/materialYouTheme.ts @@ -38,6 +38,13 @@ export function materialYouPaletteToMobileThemeVariables( "--color-card": palette.surfaceContainer, "--color-card-alt": palette.surfaceContainerHigh, "--color-card-translucent": withAlpha(palette.surfaceContainer, 0.8), + "--color-thread-canvas": palette.surfaceContainerLow, + "--color-thread-selected": palette.surfaceContainer, + "--color-thread-selected-foreground": palette.onSurface, + "--color-thread-selected-foreground-muted": palette.onSurfaceVariant, + "--color-composer-panel": withAlpha(palette.surfaceContainerLow, dark ? 0.92 : 0.88), + "--color-composer-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.9 : 0.94), + "--color-composer-border": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), "--color-foreground": palette.onSurface, "--color-foreground-secondary": palette.onSurfaceVariant, "--color-foreground-muted": palette.onSurfaceVariant, diff --git a/apps/mobile/src/lib/mobileDefaultTheme.ts b/apps/mobile/src/lib/mobileDefaultTheme.ts index 66afae46473b..4a3ac43be917 100644 --- a/apps/mobile/src/lib/mobileDefaultTheme.ts +++ b/apps/mobile/src/lib/mobileDefaultTheme.ts @@ -9,6 +9,13 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = { "--color-card": "#ffffff", "--color-card-alt": "#f5f5f5", "--color-card-translucent": "rgba(255, 255, 255, 0.8)", + "--color-thread-canvas": "#f5f5f5", + "--color-thread-selected": "#ffffff", + "--color-thread-selected-foreground": "#262626", + "--color-thread-selected-foreground-muted": "#737373", + "--color-composer-panel": "rgba(245, 245, 245, 0.88)", + "--color-composer-surface": "rgba(255, 255, 255, 0.94)", + "--color-composer-border": "rgba(0, 0, 0, 0.1)", "--color-foreground": "#262626", "--color-foreground-secondary": "#525252", "--color-foreground-muted": "#737373", @@ -76,6 +83,13 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = { "--color-card": "#171717", "--color-card-alt": "#1c1c1c", "--color-card-translucent": "rgba(17, 17, 17, 0.8)", + "--color-thread-canvas": "#1c1c1c", + "--color-thread-selected": "#171717", + "--color-thread-selected-foreground": "#f5f5f5", + "--color-thread-selected-foreground-muted": "#8e8e93", + "--color-composer-panel": "rgba(28, 28, 28, 0.92)", + "--color-composer-surface": "rgba(23, 23, 23, 0.9)", + "--color-composer-border": "rgba(255, 255, 255, 0.08)", "--color-foreground": "#f5f5f5", "--color-foreground-secondary": "#a3a3a3", "--color-foreground-muted": "#8e8e93", diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index d5744952bba4..0a6fadab1772 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -163,10 +163,16 @@ describe("mobile themes", () => { it("maps semantic palette roles onto every mobile color variable", () => { const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); - expect(Object.keys(variables)).toHaveLength(65); + expect(Object.keys(variables)).toHaveLength(72); expect(variables["--color-sheet-solid"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), ); + expect(variables["--color-thread-canvas"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.surface), + ); + expect(variables["--color-thread-selected"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.surfaceRaised), + ); expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); expect(variables["--color-primary-shadow"]).toBe("#000000"); expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 36de7f979da6..88d80cac45ba 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -217,6 +217,16 @@ export function createMobileThemeVariables( "--color-card": c.surfaceRaised, "--color-card-alt": c.surface, "--color-card-translucent": withAlpha(c.surfaceRaised, 0.8), + "--color-thread-canvas": c.surface, + "--color-thread-selected": c.surfaceRaised, + "--color-thread-selected-foreground": c.text, + "--color-thread-selected-foreground-muted": c.textMuted, + "--color-composer-panel": themeColorWithAlpha(c.surface, appearance === "dark" ? 0.92 : 0.88), + "--color-composer-surface": themeColorWithAlpha( + c.surfaceRaised, + appearance === "dark" ? 0.9 : 0.94, + ), + "--color-composer-border": themeColorWithAlpha(c.border, appearance === "dark" ? 0.46 : 0.54), "--color-foreground": c.text, "--color-foreground-secondary": c.textMuted, "--color-foreground-muted": c.mutedForeground, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 48087b38d459..1b51c4a80b3c 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -22,6 +22,7 @@ export interface Preferences { readonly darkThemeId?: MobileThemeId; readonly themeMode?: MobileThemeMode; readonly systemColorsEnabled?: boolean; + readonly materialYouStyleLayoutEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; readonly markdownFontSize?: number; @@ -90,6 +91,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { darkThemeId?: MobileThemeId; themeMode?: MobileThemeMode; systemColorsEnabled?: boolean; + materialYouStyleLayoutEnabled?: boolean; baseFontSize?: number; terminalFontSize?: number | null; markdownFontSize?: number; @@ -135,6 +137,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.systemColorsEnabled === "boolean") { preferences.systemColorsEnabled = parsed.systemColorsEnabled; } + if (typeof parsed.materialYouStyleLayoutEnabled === "boolean") { + preferences.materialYouStyleLayoutEnabled = parsed.materialYouStyleLayoutEnabled; + } if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { preferences.terminalFontSize = parsed.terminalFontSize; diff --git a/docs/user/mobile-appearance.md b/docs/user/mobile-appearance.md index f3ac966d859d..2276d364bf45 100644 --- a/docs/user/mobile-appearance.md +++ b/docs/user/mobile-appearance.md @@ -13,3 +13,12 @@ To change themes: **System** follows the device appearance automatically. Theme, text, code, and terminal appearance preferences are stored on the device. + +On Android, two optional settings can be enabled independently: + +- **System Colors** uses the colors generated from the device wallpaper on Android 12 or newer. +- **Material You Style Layout** uses Material You surfaces, shapes, switches, and spacing while + keeping the selected T3 Code theme colors. + +Both settings are disabled by default. Enabling both applies the device colors to the Material You +layout; enabling only one changes only that part of the appearance.