authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-09 10:47:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-09 10:47:15-07:00
loga29a7cc5853e16d4ca7e07acf364836f71b6101a
treeb7a16c9cedbe4819466b5ef61adb1b09d74b3867
parent6c583620ff7ad3235b135af7107c0543467eecfa
signature Signed by PGP key B5690EEEBB952194

fix(mobile): blur glass fallbacks to prevent background text bleed (#10964)


5 files changed, 119 insertions(+), 54 deletions(-)

apps/mobile/src/components/GlassSurface.tsx+34-9
...@@ -1,17 +1,20 @@...@@ -1,17 +1,20 @@
1import { BlurView } from "expo-blur";
1import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";2import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
2import type { ReactNode, Ref } from "react";3import { useContext, type ReactNode, type Ref, type RefObject } from "react";
3import {4import {
4 Platform,5 Platform,
6 StyleSheet,
5 useColorScheme,7 useColorScheme,
6 View,8 View,
7 type ColorValue,9 type ColorValue,
8 type StyleProp,
9 type ViewProps,10 type ViewProps,
10 type ViewStyle,11 type ViewStyle,
11} from "react-native";12} from "react-native";
12import { withUniwind } from "uniwind";13import { withUniwind } from "uniwind";
1314
14import { cn } from "../lib/cn";15import { cn } from "../lib/cn";
16import { GlassBlurTargetContext } from "../lib/glassBlurTarget";
17import { themeColorWithAlpha } from "../lib/mobileTheme";
1518
16// Explicit mappings keep the native glassEffectStyle enum out of style-array conversion.19// Explicit mappings keep the native glassEffectStyle enum out of style-array conversion.
17const ThemedGlassView = withUniwind(GlassView, {20const ThemedGlassView = withUniwind(GlassView, {
...@@ -26,8 +29,9 @@ interface GlassSurfaceProps extends ViewProps {...@@ -26,8 +29,9 @@ interface GlassSurfaceProps extends ViewProps {
26 readonly tintColor?: ColorValue;29 readonly tintColor?: ColorValue;
27 readonly tintColorClassName?: string;30 readonly tintColorClassName?: string;
28 readonly chrome?: "default" | "none";31 readonly chrome?: "default" | "none";
29 /** Styling used only when native Liquid Glass is unavailable. */32 /** Base color for the frosted tint, or solid fill when blur is unavailable. */
30 readonly fallbackStyle?: StyleProp<ViewStyle>;33 readonly fallbackColor?: ColorValue;
34 readonly blurTarget?: RefObject<View | null>;
31 /** Uniwind styling used only when native Liquid Glass is unavailable. */35 /** Uniwind styling used only when native Liquid Glass is unavailable. */
32 readonly fallbackClassName?: string;36 readonly fallbackClassName?: string;
33}37}
...@@ -39,13 +43,21 @@ export function GlassSurface({...@@ -39,13 +43,21 @@ export function GlassSurface({
39 chrome = "default",43 chrome = "default",
40 tintColor,44 tintColor,
41 tintColorClassName,45 tintColorClassName,
42 fallbackStyle,46 fallbackColor,
47 blurTarget,
43 fallbackClassName,48 fallbackClassName,
44 className,49 className,
45 style,50 style,
46 ...props51 ...props
47}: GlassSurfaceProps) {52}: GlassSurfaceProps) {
48 const isDarkMode = useColorScheme() === "dark";53 const isDarkMode = useColorScheme() === "dark";
54 const inheritedBlurTarget = useContext(GlassBlurTargetContext);
55 const target = blurTarget ?? inheritedBlurTarget;
56 const supportsBlur =
57 Platform.OS === "ios" ||
58 (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined);
59 const backgroundColor =
60 fallbackColor === undefined ? undefined : themeColorWithAlpha(String(fallbackColor), 1);
49 const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();61 const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();
50 const surfaceStyle: ViewStyle = {62 const surfaceStyle: ViewStyle = {
51 borderRadius: 32,63 borderRadius: 32,
...@@ -95,14 +107,27 @@ export function GlassSurface({...@@ -95,14 +107,27 @@ export function GlassSurface({
95 {...props}107 {...props}
96 ref={ref}108 ref={ref}
97 className={cn(109 className={cn(
98 chrome === "none"110 chrome === "none" ? "border-0 border-transparent" : "border border-border",
99 ? "border-0 border-transparent bg-transparent"
100 : "border border-border bg-glass-surface",
101 fallbackClassName,111 fallbackClassName,
102 className,112 className,
103 )}113 )}
104 style={[surfaceStyle, fallbackStyle, style]}114 style={[surfaceStyle, style]}
105 >115 >
116 {supportsBlur ? (
117 <BlurView
118 pointerEvents="none"
119 blurTarget={target}
120 blurMethod="dimezisBlurViewSdk31Plus"
121 intensity={80}
122 tint={isDarkMode ? "dark" : "default"}
123 style={StyleSheet.absoluteFill}
124 />
125 ) : null}
126 <View
127 pointerEvents="none"
128 className="absolute inset-0 bg-card"
129 style={{ backgroundColor, opacity: supportsBlur ? (isDarkMode ? 0.25 : 0.55) : 1 }}
130 />
106 {children}131 {children}
107 </View>132 </View>
108 );133 );
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+20-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { BlurTargetView } from "expo-blur";
1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";2import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";3import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
3import type { MenuAction } from "@react-native-menu/menu";4import type { MenuAction } from "@react-native-menu/menu";
...@@ -154,6 +155,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{...@@ -154,6 +155,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{
154}>;155}>;
155156
156export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {157export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
158 const terminalBlurTarget = useRef<View>(null);
157 const navigation = useNavigation();159 const navigation = useNavigation();
158 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");160 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
159 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");161 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
...@@ -1260,7 +1262,21 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1260,7 +1262,21 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1260 />1262 />
1261 ) : (1263 ) : (
1262 <>1264 <>
1263 <View className="flex-1" style={{ paddingBottom: terminalBottomInset }}>1265 <BlurTargetView
1266 ref={terminalBlurTarget}
1267 style={{
1268 flex: 1,
1269 paddingBottom: terminalBottomInset,
1270 }}
1271 >
1272 <View
1273 pointerEvents="none"
1274 style={{
1275 position: "absolute",
1276 inset: 0,
1277 backgroundColor: terminalTheme.background,
1278 }}
1279 />
1264 <TerminalSurface1280 <TerminalSurface
1265 autoFocus={!SHOWCASE_ENABLED}1281 autoFocus={!SHOWCASE_ENABLED}
1266 buffer={terminalSurfaceBuffer}1282 buffer={terminalSurfaceBuffer}
...@@ -1273,7 +1289,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1273,7 +1289,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1273 terminalKey={terminalKey}1289 terminalKey={terminalKey}
1274 theme={terminalTheme}1290 theme={terminalTheme}
1275 />1291 />
1276 </View>1292 </BlurTargetView>
12771293
1278 {isAccessoryVisible ? (1294 {isAccessoryVisible ? (
1279 <KeyboardStickyView1295 <KeyboardStickyView
...@@ -1340,6 +1356,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -1340,6 +1356,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
1340 >1356 >
1341 <GlassSurface1357 <GlassSurface
1342 chrome="none"1358 chrome="none"
1359 blurTarget={terminalBlurTarget}
1360 fallbackColor={terminalTheme.background}
1343 glassEffectStyle="regular"1361 glassEffectStyle="regular"
1344 tintColor="transparent"1362 tintColor="transparent"
1345 style={{1363 style={{
apps/mobile/src/features/threads/ThreadComposer.tsx+6-4
...@@ -143,7 +143,7 @@ export interface ThreadComposerProps {...@@ -143,7 +143,7 @@ export interface ThreadComposerProps {
143143
144/**144/**
145 * The pill / card container — renders with Expo's native GlassView on supported145 * The pill / card container — renders with Expo's native GlassView on supported
146 * iOS 26+ devices and keeps the existing opaque fallback elsewhere.146 * iOS 26+ devices, with a frosted blur fallback where supported.
147 * Exported so NewTaskDraftScreen can render the same composer chrome.147 * Exported so NewTaskDraftScreen can render the same composer chrome.
148 */148 */
149// The bottom-anchored dock position and clipped surface height use the same149// The bottom-anchored dock position and clipped surface height use the same
...@@ -172,6 +172,7 @@ export function ComposerSurface(props: {...@@ -172,6 +172,7 @@ export function ComposerSurface(props: {
172 readonly animateLayout?: boolean;172 readonly animateLayout?: boolean;
173}) {173}) {
174 const { materialYouStyleLayoutActive } = useAppearancePreferences();174 const { materialYouStyleLayoutActive } = useAppearancePreferences();
175 const colors = useUniwindTheme();
175 const targetBorderRadius =176 const targetBorderRadius =
176 typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;177 typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;
177 const animatedBorderRadius = useSharedValue(targetBorderRadius);178 const animatedBorderRadius = useSharedValue(targetBorderRadius);
...@@ -210,10 +211,11 @@ export function ComposerSurface(props: {...@@ -210,10 +211,11 @@ export function ComposerSurface(props: {
210 >211 >
211 <AnimatedGlassSurface212 <AnimatedGlassSurface
212 chrome="none"213 chrome="none"
214 fallbackColor={
215 materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"]
216 }
213 fallbackClassName={217 fallbackClassName={
214 materialYouStyleLayoutActive218 materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border"
215 ? "border border-composer-border bg-composer-surface"
216 : "border border-border bg-card-translucent"
217 }219 }
218 glassEffectStyle="regular"220 glassEffectStyle="regular"
219 // The composer is a passive material containing interactive controls.221 // The composer is a passive material containing interactive controls.
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+53-39
...@@ -26,6 +26,8 @@ import type {...@@ -26,6 +26,8 @@ import type {
26 UserInputQuestion,26 UserInputQuestion,
27} from "@t3tools/contracts";27} from "@t3tools/contracts";
28import * as Haptics from "expo-haptics";28import * as Haptics from "expo-haptics";
29import { BlurTargetView } from "expo-blur";
30import { GlassBlurTargetContext } from "../../lib/glassBlurTarget";
29import {31import {
30 memo,32 memo,
31 useCallback,33 useCallback,
...@@ -807,7 +809,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -807,7 +809,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
807 }, [freeze, scrollMessageToEnd]);809 }, [freeze, scrollMessageToEnd]);
808810
809 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;811 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
810 const { themeAppearance } = useAppearancePreferences();812 const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences();
811 const isDarkMode = themeAppearance === "dark";813 const isDarkMode = themeAppearance === "dark";
812814
813 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {815 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
...@@ -839,17 +841,27 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -839,17 +841,27 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
839 const handleFeedTouchCancel = useCallback(() => {841 const handleFeedTouchCancel = useCallback(() => {
840 feedTouchStartRef.current = null;842 feedTouchStartRef.current = null;
841 }, []);843 }, []);
844 const feedBlurTarget = useRef<View>(null);
842845
843 return (846 return (
844 <View className="flex-1">847 <View className="flex-1">
845 {showContent ? (848 {showContent ? (
846 <View849 <BlurTargetView
847 className="flex-1"850 ref={feedBlurTarget}
851 style={{ flex: 1 }}
848 onTouchStart={handleFeedTouchStart}852 onTouchStart={handleFeedTouchStart}
849 onTouchMove={handleFeedTouchMove}853 onTouchMove={handleFeedTouchMove}
850 onTouchEnd={handleFeedTouchEnd}854 onTouchEnd={handleFeedTouchEnd}
851 onTouchCancel={handleFeedTouchCancel}855 onTouchCancel={handleFeedTouchCancel}
852 >856 >
857 <View
858 pointerEvents="none"
859 className={
860 materialYouStyleLayoutActive
861 ? "absolute inset-0 bg-thread-canvas"
862 : "absolute inset-0 bg-screen"
863 }
864 />
853 <ThreadFeed865 <ThreadFeed
854 key={selectedThreadKey}866 key={selectedThreadKey}
855 environmentId={props.environmentId}867 environmentId={props.environmentId}
...@@ -881,7 +893,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -881,7 +893,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
881 onUseArtifactTemplate={handleUseArtifactTemplate}893 onUseArtifactTemplate={handleUseArtifactTemplate}
882 loadEarlier={props.loadEarlier ?? null}894 loadEarlier={props.loadEarlier ?? null}
883 />895 />
884 </View>896 </BlurTargetView>
885 ) : (897 ) : (
886 <View className="flex-1" />898 <View className="flex-1" />
887 )}899 )}
...@@ -1003,41 +1015,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1003,41 +1015,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1003 : undefined1015 : undefined
1004 }1016 }
1005 >1017 >
1006 <ThreadComposer1018 <GlassBlurTargetContext value={feedBlurTarget}>
1007 editorRef={composerEditorRef}1019 <ThreadComposer
1008 draftMessage={props.draftMessage}1020 editorRef={composerEditorRef}
1009 draftAttachments={props.draftAttachments}1021 draftMessage={props.draftMessage}
1010 placeholder="Ask the repo agent, or run a command…"1022 draftAttachments={props.draftAttachments}
1011 contentMaxWidth={contentMaxWidth}1023 placeholder="Ask the repo agent, or run a command…"
1012 connectionState={props.connectionStateLabel}1024 contentMaxWidth={contentMaxWidth}
1013 environmentLabel={props.environmentLabel}1025 connectionState={props.connectionStateLabel}
1014 selectedThread={props.selectedThread}1026 environmentLabel={props.environmentLabel}
1015 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}1027 selectedThread={props.selectedThread}
1016 serverConfig={props.serverConfig}1028 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
1017 queueCount={props.selectedThreadQueueCount}1029 serverConfig={props.serverConfig}
1018 environmentId={props.environmentId}1030 queueCount={props.selectedThreadQueueCount}
1019 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}1031 environmentId={props.environmentId}
1020 // Follow-ups typed during setup wait in the draft: queueing1032 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
1021 // them against a thread id the server may still reject1033 // Follow-ups typed during setup wait in the draft: queueing
1022 // would strand them in the outbox.1034 // them against a thread id the server may still reject
1023 sendBlockedReason={1035 // would strand them in the outbox.
1024 props.creationState?.kind === "preparing" ? "Starting the task…" : null1036 sendBlockedReason={
1025 }1037 props.creationState?.kind === "preparing" ? "Starting the task…" : null
1026 bottomInset={composerBottomInset}1038 }
1027 onChangeDraftMessage={props.onChangeDraftMessage}1039 bottomInset={composerBottomInset}
1028 onPickDraftMedia={props.onPickDraftMedia}1040 onChangeDraftMessage={props.onChangeDraftMessage}
1029 onPickDraftFiles={props.onPickDraftFiles}1041 onPickDraftMedia={props.onPickDraftMedia}
1030 onNativePasteImages={props.onNativePasteImages}1042 onPickDraftFiles={props.onPickDraftFiles}
1031 onRemoveDraftImage={props.onRemoveDraftImage}1043 onNativePasteImages={props.onNativePasteImages}
1032 onStopThread={props.onStopThread}1044 onRemoveDraftImage={props.onRemoveDraftImage}
1033 onSendMessage={handleSendMessage}1045 onStopThread={props.onStopThread}
1034 onShowUsageLimits={showUsageLimits}1046 onSendMessage={handleSendMessage}
1035 onUpdateModelSelection={props.onUpdateThreadModelSelection}1047 onShowUsageLimits={showUsageLimits}
1036 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}1048 onUpdateModelSelection={props.onUpdateThreadModelSelection}
1037 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}1049 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
1038 onExpandedChange={setComposerExpanded}1050 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
1039 onEditorFocusChange={handleComposerFocusChange}1051 onExpandedChange={setComposerExpanded}
1040 />1052 onEditorFocusChange={handleComposerFocusChange}
1053 />
1054 </GlassBlurTargetContext>
1041 </View>1055 </View>
1042 </View>1056 </View>
1043 </Animated.View>1057 </Animated.View>
apps/mobile/src/lib/glassBlurTarget.ts created+6
...@@ -0,0 +1,6 @@
1import { createContext, type RefObject } from "react";
2import type { View } from "react-native";
3
4// Android cannot sample a target that contains the BlurView itself. Keep the
5// feed in a separate target, shared by the composer and its popovers.
6export const GlassBlurTargetContext = createContext<RefObject<View | null> | undefined>(undefined);