diff --git a/apps/mobile/generated-uniwind-default-theme-variables.json b/apps/mobile/generated-uniwind-default-theme-variables.json index 1953880fa946..3f1e143feb73 100644 --- a/apps/mobile/generated-uniwind-default-theme-variables.json +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -6,6 +6,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)", "--color-foreground": "#262626", "--color-foreground-secondary": "#525252", "--color-foreground-muted": "#737373", @@ -76,6 +83,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)", "--color-foreground": "#f5f5f5", "--color-foreground-secondary": "#a3a3a3", "--color-foreground-muted": "#8e8e93", diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index e9352e1b38b9..d63fe16d578b 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -128,6 +128,13 @@ --color-card: #fdfafd; --color-card-alt: #faf3fb; --color-card-translucent: rgba(253, 250, 253, 0.8); + --color-thread-canvas: #faf3fb; + --color-thread-selected: #fdfafd; + --color-thread-selected-foreground: #501854; + --color-thread-selected-foreground-muted: #ac1668; + --color-composer-panel: rgba(250, 243, 251, 0.88); + --color-composer-surface: rgba(253, 250, 253, 0.94); + --color-composer-border: rgba(238, 225, 237, 0.54); --color-foreground: #501854; --color-foreground-secondary: #ac1668; --color-foreground-muted: #8d1255; @@ -256,6 +263,13 @@ --color-card: #2c2631; --color-card-alt: #29232d; --color-card-translucent: rgba(44, 38, 49, 0.8); + --color-thread-canvas: #29232d; + --color-thread-selected: #2c2631; + --color-thread-selected-foreground: #f9f8fb; + --color-thread-selected-foreground-muted: #e7d0dd; + --color-composer-panel: rgba(41, 35, 45, 0.92); + --color-composer-surface: rgba(44, 38, 49, 0.9); + --color-composer-border: rgba(39, 36, 44, 0.46); --color-foreground: #f9f8fb; --color-foreground-secondary: #e7d0dd; --color-foreground-muted: #e7d0dd; @@ -384,6 +398,13 @@ --color-card: #ecefed; --color-card-alt: #f3f7f4; --color-card-translucent: rgba(236, 239, 237, 0.8); + --color-thread-canvas: #f3f7f4; + --color-thread-selected: #ecefed; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #746c73; + --color-composer-panel: rgba(243, 247, 244, 0.88); + --color-composer-surface: rgba(236, 239, 237, 0.94); + --color-composer-border: rgba(203, 213, 209, 0.54); --color-foreground: #241523; --color-foreground-secondary: #746c73; --color-foreground-muted: #6e696f; @@ -512,6 +533,13 @@ --color-card: #36413c; --color-card-alt: #1b2821; --color-card-translucent: rgba(54, 65, 60, 0.8); + --color-thread-canvas: #1b2821; + --color-thread-selected: #36413c; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #919595; + --color-composer-panel: rgba(27, 40, 33, 0.92); + --color-composer-surface: rgba(54, 65, 60, 0.9); + --color-composer-border: rgba(65, 95, 79, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #919595; --color-foreground-muted: #9da5a2; @@ -640,6 +668,13 @@ --color-card: #edeff1; --color-card-alt: #f5f7f8; --color-card-translucent: rgba(237, 239, 241, 0.8); + --color-thread-canvas: #f5f7f8; + --color-thread-selected: #edeff1; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #746c75; + --color-composer-panel: rgba(245, 247, 248, 0.88); + --color-composer-surface: rgba(237, 239, 241, 0.94); + --color-composer-border: rgba(205, 212, 220, 0.54); --color-foreground: #241523; --color-foreground-secondary: #746c75; --color-foreground-muted: #6f6873; @@ -768,6 +803,13 @@ --color-card: #333b45; --color-card-alt: #17212b; --color-card-translucent: rgba(51, 59, 69, 0.8); + --color-thread-canvas: #17212b; + --color-thread-selected: #333b45; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #8d8f97; + --color-composer-panel: rgba(23, 33, 43, 0.92); + --color-composer-surface: rgba(51, 59, 69, 0.9); + --color-composer-border: rgba(64, 85, 103, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #8d8f97; --color-foreground-muted: #969ca6; @@ -896,6 +938,13 @@ --color-card: #f1efee; --color-card-alt: #f9f7f5; --color-card-translucent: rgba(241, 239, 238, 0.8); + --color-thread-canvas: #f9f7f5; + --color-thread-selected: #f1efee; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #766c74; + --color-composer-panel: rgba(249, 247, 245, 0.88); + --color-composer-surface: rgba(241, 239, 238, 0.94); + --color-composer-border: rgba(221, 210, 206, 0.54); --color-foreground: #241523; --color-foreground-secondary: #766c74; --color-foreground-muted: #74686f; @@ -1024,6 +1073,13 @@ --color-card: #433835; --color-card-alt: #291e1a; --color-card-translucent: rgba(67, 56, 53, 0.8); + --color-thread-canvas: #291e1a; + --color-thread-selected: #433835; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #968e8f; + --color-composer-panel: rgba(41, 30, 26, 0.92); + --color-composer-surface: rgba(67, 56, 53, 0.9); + --color-composer-border: rgba(102, 76, 63, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #968e8f; --color-foreground-muted: #a59996; @@ -1152,6 +1208,13 @@ --color-card: #f0eff2; --color-card-alt: #f8f7f9; --color-card-translucent: rgba(240, 239, 242, 0.8); + --color-thread-canvas: #f8f7f9; + --color-thread-selected: #f0eff2; + --color-thread-selected-foreground: #241523; + --color-thread-selected-foreground-muted: #766c76; + --color-composer-panel: rgba(248, 247, 249, 0.88); + --color-composer-surface: rgba(240, 239, 242, 0.94); + --color-composer-border: rgba(214, 209, 222, 0.54); --color-foreground: #241523; --color-foreground-secondary: #766c76; --color-foreground-muted: #726874; @@ -1280,6 +1343,13 @@ --color-card: #383443; --color-card-alt: #1d1929; --color-card-translucent: rgba(56, 52, 67, 0.8); + --color-thread-canvas: #1d1929; + --color-thread-selected: #383443; + --color-thread-selected-foreground: #fffaff; + --color-thread-selected-foreground-muted: #8e8a95; + --color-composer-panel: rgba(29, 25, 41, 0.92); + --color-composer-surface: rgba(56, 52, 67, 0.9); + --color-composer-border: rgba(77, 67, 102, 0.46); --color-foreground: #fffaff; --color-foreground-secondary: #8e8a95; --color-foreground-muted: #9690a1; diff --git a/apps/mobile/global.css b/apps/mobile/global.css index e153107b1811..6ec28f6e3744 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -19,6 +19,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; @@ -123,6 +130,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 46bc2c7c0912..f397c18a0a8f 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -48,6 +48,7 @@ export function AndroidScreenHeader(props: { readonly trailing?: ReactNode; readonly onBack?: () => void; readonly embedded?: boolean; + readonly hideBottomBorder?: boolean; }) { const insets = useSafeAreaInsets(); @@ -56,6 +57,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 5b4603fd1201..08cef56f3ec0 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,6 +1,52 @@ -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 { SymbolView } from "./AppSymbol"; export function ThemedSwitch(props: SwitchProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + if (materialYouStyleLayoutActive) { + return ( + props.onValueChange?.(!props.value)} + style={props.style} + testID={props.testID} + className={props.disabled ? "opacity-40" : "active:opacity-70"} + > + + + + + + + ); + } + return ( {/* 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, @@ -466,6 +470,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { title="Files" subtitle={projectName} onBack={handleReturnToThread} + hideBottomBorder={materialYouStyleLayoutActive} actions={[ { accessibilityLabel: "Refresh files", @@ -474,7 +479,13 @@ 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 cbeadf59f6f6..f67c2bff0f68 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,3 +1,4 @@ +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import Constants from "expo-constants"; @@ -66,6 +67,7 @@ function checkedMenuState(checked: boolean) { } function AndroidHomeHeader(props: HomeHeaderProps) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const insets = useSafeAreaInsets(); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the @@ -199,7 +201,11 @@ function AndroidHomeHeader(props: HomeHeaderProps) { <> - + >(() => new Map()); @@ -1081,26 +1084,31 @@ export function HomeScreen(props: HomeScreenProps) { if (!hasAnyThreads) { return ( - - - - {emptyState.loading ? ( - - - - ) : null} + + + + + {emptyState.loading ? ( + + + + ) : null} + ); @@ -1145,100 +1153,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 34bc450a4ed9..4d9968cca097 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -47,6 +47,7 @@ import { } from "../keyboard/hardwareKeyboardCommands"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; 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"; @@ -219,6 +220,7 @@ function AdaptiveWorkspaceLayoutContent( }, ) { const projectGroupingMode = props.projectGroupingMode; + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -567,7 +569,14 @@ function AdaptiveWorkspaceLayoutContent( ) : null} - + void }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); return ( - + void; readonly systemColorsAvailable: boolean; readonly systemColorsActive: boolean; readonly themeVariables: MobileThemeVariables; @@ -94,6 +97,8 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [resolvedThemeIds.dark, resolvedThemeIds.light], ); const themeId = themeIds[themeAppearance]; + const materialYouStyleLayoutEnabled = storedPreferences?.materialYouStyleLayoutEnabled ?? false; + const materialYouStyleLayoutActive = Platform.OS === "android" && materialYouStyleLayoutEnabled; const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); useEffect(() => { @@ -242,6 +247,13 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [runtimeState, syncThemeRuntime, updateThemePreferences], ); + const setMaterialYouStyleLayoutEnabled = useCallback( + (value: boolean) => { + updatePreferences({ materialYouStyleLayoutEnabled: value }); + }, + [updatePreferences], + ); + const setBaseFontSize = useCallback( (value: number) => { const current = appliedRuntimeStateRef.current ?? runtimeState; @@ -281,6 +293,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeAppearance, systemColorsAvailable: isSystemColorsAvailable, systemColorsActive, + materialYouStyleLayoutEnabled, + materialYouStyleLayoutActive, + setMaterialYouStyleLayoutEnabled, themeVariables, themeVariablesByAppearance, systemColorPalettes, @@ -300,6 +315,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeMode, themeAppearance, systemColorsActive, + materialYouStyleLayoutEnabled, + materialYouStyleLayoutActive, + setMaterialYouStyleLayoutEnabled, themeVariables, themeVariablesByAppearance, systemColorPalettes, diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index 788d04b047ba..a677cc5707d2 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 { ScopedTheme, ScopedVariables } from "uniwind"; @@ -19,6 +19,9 @@ import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; import { cn } from "../../../../lib/cn"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +import { SettingsSection } from "../../components/SettingsSection"; +import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; + const APPEARANCE_MODES: ReadonlyArray<{ readonly id: MobileThemeMode; readonly label: string; @@ -290,11 +293,25 @@ export function ThemeAppearanceSection() { setThemeMode, themeIds, themeMode, + materialYouStyleLayoutEnabled, + setMaterialYouStyleLayoutEnabled, systemColorsAvailable, } = useAppearancePreferences(); return ( + {Platform.OS === "android" ? ( + + + + ) : null} Color scheme diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 684b69c843a5..bdc26628dd1c 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,3 +1,4 @@ +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId, @@ -170,6 +171,7 @@ export function ComposerSurface(props: { /** Morphs between the compact and expanded composer layouts. */ readonly animateLayout?: boolean; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const targetBorderRadius = typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; const animatedBorderRadius = useSharedValue(targetBorderRadius); @@ -191,7 +193,11 @@ export function ComposerSurface(props: { // clip leaves the glass and content at their final height on the first frame. return ( {/* 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. */} ); case "v2-settled-shelf": @@ -966,7 +972,7 @@ function ThreadNavigationSidebarPane( disabled={!shelfPreferencesLoaded} expanded={item.expanded} onToggle={toggleSettledShelf} - pane="sidebar" + pane={materialYouStyleLayoutActive ? "screen" : "sidebar"} /> ); case "v2-show-more": @@ -986,7 +992,7 @@ function ThreadNavigationSidebarPane( case "header": return ( - + - + + {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 55f122d6389a..0ff42a077b04 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -76,6 +76,7 @@ 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 { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAdaptiveWorkspaceLayout, useAdaptiveWorkspacePaneRole, @@ -193,6 +194,8 @@ function ThreadRouteContent( readonly selectedThreadDetailState: ReturnType; }, ) { + const { materialYouStyleLayoutActive, themeVariables } = useAppearancePreferences(); + const headerColor = themeVariables["--color-header"]; const { fileInspector, layout, @@ -839,7 +842,19 @@ function ThreadRouteContent( - + (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) : undefined, unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, + contentStyle: + Platform.OS === "android" && materialYouStyleLayoutActive + ? { backgroundColor: headerColor } + : undefined, }} /> @@ -940,6 +959,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 24cdb0cc4546..2d4276606ec3 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -52,6 +52,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useNewTaskFlow } from "./new-task-flow-provider"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { createProviderCatalogRefreshRunner, providerCatalogRefreshError, @@ -104,6 +105,8 @@ function ModelRow(props: { readonly isFirst: boolean; readonly isLast: boolean; }) { + const { materialYouStyleLayoutActive } = useAppearancePreferences(); + const selectedMaterialRow = materialYouStyleLayoutActive && props.selected; return ( ["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); @@ -475,6 +476,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const drawerColor = theme["--color-drawer"]; const pressedBackgroundColor = theme["--color-subtle"]; const selectedBackgroundColor = theme["--color-user-bubble"]; + const materialSelectedBackgroundColor = theme["--color-thread-selected"]; + const materialSelectedForegroundColor = theme["--color-thread-selected-foreground"]; const selectedForegroundColor = theme["--color-user-bubble-foreground"]; const { @@ -502,15 +505,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; @@ -581,7 +594,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { compact ? "accent-icon-subtle" : selected - ? "accent-user-bubble-foreground-muted" + ? materialYouStyleLayoutActive + ? "accent-thread-selected-foreground-muted" + : "accent-user-bubble-foreground-muted" : "accent-foreground-muted" } /> @@ -589,9 +604,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { @@ -604,12 +622,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {pr.label} @@ -626,6 +650,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { accessibilityLabel={threadAccessibilityLabel} accessibilityRole="button" className="bg-screen active:opacity-70" + style={ + materialYouStyleLayoutActive + ? { + backgroundColor: visuallySelected ? effectiveSelectedBackground : backgroundColor, + borderRadius: SIDEBAR_ROW_RADIUS, + } + : undefined + } onPress={() => { close(); onSelectThread(thread); @@ -634,13 +666,36 @@ export const ThreadListRow = memo(function ThreadListRow(props: { - + {thread.title} - {props.hasQueuedMessages ? : null} + {props.hasQueuedMessages ? ( + + ) : null} {statusPill} - {timestamp} + + {timestamp} + ) : null} {subtitleRow} @@ -673,8 +729,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { onSelectThread(thread); }} style={({ pressed }) => ({ - backgroundColor: selected - ? selectedBackgroundColor + backgroundColor: visuallySelected + ? effectiveSelectedBackground : pressed || hovered ? effectivePressedBackground : backgroundColor, @@ -691,19 +747,29 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {thread.title} - {props.hasQueuedMessages ? : null} + {props.hasQueuedMessages ? ( + + ) : null} {statusPill} @@ -715,7 +781,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null} {subtitleRow} @@ -728,7 +794,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: { threadKey={`${thread.environmentId}:${thread.id}`} backgroundColor={backgroundColor} containerStyle={ - compact ? undefined : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" } + compact && !materialYouStyleLayoutActive + ? undefined + : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" } } enableTrackpadSwipe fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 73c6084cff61..518804365879 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1,3 +1,4 @@ +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -429,11 +430,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const pr = useThreadPr(thread); + const { materialYouStyleLayoutActive } = useAppearancePreferences(); const theme = useUniwindTheme(); const screenColor = theme["--color-screen"]; const drawerColor = theme["--color-drawer"]; const pressedBackgroundColor = theme["--color-subtle"]; - const selectedBackgroundColor = theme["--color-user-bubble"]; + const selectedBackgroundColor = + theme[materialYouStyleLayoutActive ? "--color-thread-selected" : "--color-user-bubble"]; const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; // The provider badge's border blends into the row's own surface, which @@ -720,7 +723,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -739,7 +746,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"), )} > @@ -749,7 +758,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -769,7 +782,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -786,7 +803,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -794,7 +815,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -806,7 +831,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {props.environmentLabel} @@ -818,7 +847,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { kind={props.environmentMachine} size={11} tintColorClassName={ - selected ? "accent-user-bubble-foreground-muted" : "accent-foreground-tertiary" + selected + ? materialYouStyleLayoutActive + ? "accent-thread-selected-foreground-muted" + : "accent-user-bubble-foreground-muted" + : "accent-foreground-tertiary" } /> ) : null} @@ -829,7 +862,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {pr ? ( #{pr.label} @@ -863,16 +903,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 }) } @@ -884,7 +925,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 ? ( @@ -900,19 +941,21 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } 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 }) @@ -940,7 +983,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -959,7 +1006,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-foreground-secondary" : "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 9c478f3c4504..bc10bd9fe922 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-foreground-secondary" : "text-adaptive-emerald-600-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 7d4676c06e76..af52c2635e36 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/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index 652de9296f1f..def50874c210 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -153,7 +153,7 @@ 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(68); + expect(Object.keys(variables)).toHaveLength(75); expect(variables["--color-sheet-solid"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), ); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index d3b8e164be32..b2986906296f 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -218,6 +218,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/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 52202bdc5e7b..856e253923f9 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -204,6 +204,23 @@ describe("mobile connection storage", () => { await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); }); + it("persists the Material You layout independently of the selected theme", async () => { + await savePreferencesPatch({ + lightThemeId: "material-you", + materialYouStyleLayoutEnabled: true, + }); + await savePreferencesPatch({ lightThemeId: "t3-chat" }); + await expect(loadPreferences()).resolves.toEqual({ + lightThemeId: "t3-chat", + materialYouStyleLayoutEnabled: true, + }); + await savePreferencesPatch({ materialYouStyleLayoutEnabled: false }); + await expect(loadPreferences()).resolves.toEqual({ + lightThemeId: "t3-chat", + materialYouStyleLayoutEnabled: false, + }); + }); + it("drops the removed theme transition preference", async () => { mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index ce2c3e67d533..8209f6103bcc 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 materialYouStyleLayoutEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; readonly markdownFontSize?: number; @@ -90,6 +91,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { lightThemeId?: MobileThemeId; darkThemeId?: MobileThemeId; themeMode?: MobileThemeMode; + materialYouStyleLayoutEnabled?: boolean; baseFontSize?: number; terminalFontSize?: number | null; markdownFontSize?: number; @@ -133,6 +135,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.themeMode = parsed.themeMode; } + 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/appearance.md b/docs/user/appearance.md index b37c91759afd..f682902586f1 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -10,6 +10,8 @@ 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. +Android also offers **Material You Layout**, which changes shapes and spacing independently +of the selected theme. ## Motion