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 @@
1import { BlurView } from "expo-blur";
12import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
2import type { ReactNode, Ref } from "react";
3import { useContext, type ReactNode, type Ref, type RefObject } from "react";
34import {
45 Platform,
6 StyleSheet,
57 useColorScheme,
68 View,
79 type ColorValue,
8 type StyleProp,
910 type ViewProps,
1011 type ViewStyle,
1112} from "react-native";
1213import { withUniwind } from "uniwind";
1314
1415import { cn } from "../lib/cn";
16import { GlassBlurTargetContext } from "../lib/glassBlurTarget";
17import { themeColorWithAlpha } from "../lib/mobileTheme";
1518
1619// Explicit mappings keep the native glassEffectStyle enum out of style-array conversion.
1720const ThemedGlassView = withUniwind(GlassView, {
......@@ -26,8 +29,9 @@ interface GlassSurfaceProps extends ViewProps {
2629 readonly tintColor?: ColorValue;
2730 readonly tintColorClassName?: string;
2831 readonly chrome?: "default" | "none";
29 /** Styling used only when native Liquid Glass is unavailable. */
30 readonly fallbackStyle?: StyleProp<ViewStyle>;
32 /** Base color for the frosted tint, or solid fill when blur is unavailable. */
33 readonly fallbackColor?: ColorValue;
34 readonly blurTarget?: RefObject<View | null>;
3135 /** Uniwind styling used only when native Liquid Glass is unavailable. */
3236 readonly fallbackClassName?: string;
3337}
......@@ -39,13 +43,21 @@ export function GlassSurface({
3943 chrome = "default",
4044 tintColor,
4145 tintColorClassName,
42 fallbackStyle,
46 fallbackColor,
47 blurTarget,
4348 fallbackClassName,
4449 className,
4550 style,
4651 ...props
4752}: GlassSurfaceProps) {
4853 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);
4961 const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();
5062 const surfaceStyle: ViewStyle = {
5163 borderRadius: 32,
......@@ -95,14 +107,27 @@ export function GlassSurface({
95107 {...props}
96108 ref={ref}
97109 className={cn(
98 chrome === "none"
99 ? "border-0 border-transparent bg-transparent"
100 : "border border-border bg-glass-surface",
110 chrome === "none" ? "border-0 border-transparent" : "border border-border",
101111 fallbackClassName,
102112 className,
103113 )}
104 style={[surfaceStyle, fallbackStyle, style]}
114 style={[surfaceStyle, style]}
105115 >
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 />
106131 {children}
107132 </View>
108133 );
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+20-2
......@@ -1,3 +1,4 @@
1import { BlurTargetView } from "expo-blur";
12import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
23import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
34import type { MenuAction } from "@react-native-menu/menu";
......@@ -154,6 +155,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{
154155}>;
155156
156157export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
158 const terminalBlurTarget = useRef<View>(null);
157159 const navigation = useNavigation();
158160 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
159161 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
......@@ -1260,7 +1262,21 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
12601262 />
12611263 ) : (
12621264 <>
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 />
12641280 <TerminalSurface
12651281 autoFocus={!SHOWCASE_ENABLED}
12661282 buffer={terminalSurfaceBuffer}
......@@ -1273,7 +1289,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
12731289 terminalKey={terminalKey}
12741290 theme={terminalTheme}
12751291 />
1276 </View>
1292 </BlurTargetView>
12771293
12781294 {isAccessoryVisible ? (
12791295 <KeyboardStickyView
......@@ -1340,6 +1356,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
13401356 >
13411357 <GlassSurface
13421358 chrome="none"
1359 blurTarget={terminalBlurTarget}
1360 fallbackColor={terminalTheme.background}
13431361 glassEffectStyle="regular"
13441362 tintColor="transparent"
13451363 style={{
apps/mobile/src/features/threads/ThreadComposer.tsx+6-4
......@@ -143,7 +143,7 @@ export interface ThreadComposerProps {
143143
144144/**
145145 * 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.
147147 * Exported so NewTaskDraftScreen can render the same composer chrome.
148148 */
149149// The bottom-anchored dock position and clipped surface height use the same
......@@ -172,6 +172,7 @@ export function ComposerSurface(props: {
172172 readonly animateLayout?: boolean;
173173}) {
174174 const { materialYouStyleLayoutActive } = useAppearancePreferences();
175 const colors = useUniwindTheme();
175176 const targetBorderRadius =
176177 typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0;
177178 const animatedBorderRadius = useSharedValue(targetBorderRadius);
......@@ -210,10 +211,11 @@ export function ComposerSurface(props: {
210211 >
211212 <AnimatedGlassSurface
212213 chrome="none"
214 fallbackColor={
215 materialYouStyleLayoutActive ? colors["--color-composer-surface"] : colors["--color-card"]
216 }
213217 fallbackClassName={
214 materialYouStyleLayoutActive
215 ? "border border-composer-border bg-composer-surface"
216 : "border border-border bg-card-translucent"
218 materialYouStyleLayoutActive ? "border border-composer-border" : "border border-border"
217219 }
218220 glassEffectStyle="regular"
219221 // The composer is a passive material containing interactive controls.
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+53-39
......@@ -26,6 +26,8 @@ import type {
2626 UserInputQuestion,
2727} from "@t3tools/contracts";
2828import * as Haptics from "expo-haptics";
29import { BlurTargetView } from "expo-blur";
30import { GlassBlurTargetContext } from "../../lib/glassBlurTarget";
2931import {
3032 memo,
3133 useCallback,
......@@ -807,7 +809,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
807809 }, [freeze, scrollMessageToEnd]);
808810
809811 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
810 const { themeAppearance } = useAppearancePreferences();
812 const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences();
811813 const isDarkMode = themeAppearance === "dark";
812814
813815 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
......@@ -839,17 +841,27 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
839841 const handleFeedTouchCancel = useCallback(() => {
840842 feedTouchStartRef.current = null;
841843 }, []);
844 const feedBlurTarget = useRef<View>(null);
842845
843846 return (
844847 <View className="flex-1">
845848 {showContent ? (
846 <View
847 className="flex-1"
849 <BlurTargetView
850 ref={feedBlurTarget}
851 style={{ flex: 1 }}
848852 onTouchStart={handleFeedTouchStart}
849853 onTouchMove={handleFeedTouchMove}
850854 onTouchEnd={handleFeedTouchEnd}
851855 onTouchCancel={handleFeedTouchCancel}
852856 >
857 <View
858 pointerEvents="none"
859 className={
860 materialYouStyleLayoutActive
861 ? "absolute inset-0 bg-thread-canvas"
862 : "absolute inset-0 bg-screen"
863 }
864 />
853865 <ThreadFeed
854866 key={selectedThreadKey}
855867 environmentId={props.environmentId}
......@@ -881,7 +893,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
881893 onUseArtifactTemplate={handleUseArtifactTemplate}
882894 loadEarlier={props.loadEarlier ?? null}
883895 />
884 </View>
896 </BlurTargetView>
885897 ) : (
886898 <View className="flex-1" />
887899 )}
......@@ -1003,41 +1015,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
10031015 : undefined
10041016 }
10051017 >
1006 <ThreadComposer
1007 editorRef={composerEditorRef}
1008 draftMessage={props.draftMessage}
1009 draftAttachments={props.draftAttachments}
1010 placeholder="Ask the repo agent, or run a command…"
1011 contentMaxWidth={contentMaxWidth}
1012 connectionState={props.connectionStateLabel}
1013 environmentLabel={props.environmentLabel}
1014 selectedThread={props.selectedThread}
1015 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
1016 serverConfig={props.serverConfig}
1017 queueCount={props.selectedThreadQueueCount}
1018 environmentId={props.environmentId}
1019 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
1020 // Follow-ups typed during setup wait in the draft: queueing
1021 // them against a thread id the server may still reject
1022 // would strand them in the outbox.
1023 sendBlockedReason={
1024 props.creationState?.kind === "preparing" ? "Starting the task…" : null
1025 }
1026 bottomInset={composerBottomInset}
1027 onChangeDraftMessage={props.onChangeDraftMessage}
1028 onPickDraftMedia={props.onPickDraftMedia}
1029 onPickDraftFiles={props.onPickDraftFiles}
1030 onNativePasteImages={props.onNativePasteImages}
1031 onRemoveDraftImage={props.onRemoveDraftImage}
1032 onStopThread={props.onStopThread}
1033 onSendMessage={handleSendMessage}
1034 onShowUsageLimits={showUsageLimits}
1035 onUpdateModelSelection={props.onUpdateThreadModelSelection}
1036 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
1037 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
1038 onExpandedChange={setComposerExpanded}
1039 onEditorFocusChange={handleComposerFocusChange}
1040 />
1018 <GlassBlurTargetContext value={feedBlurTarget}>
1019 <ThreadComposer
1020 editorRef={composerEditorRef}
1021 draftMessage={props.draftMessage}
1022 draftAttachments={props.draftAttachments}
1023 placeholder="Ask the repo agent, or run a command…"
1024 contentMaxWidth={contentMaxWidth}
1025 connectionState={props.connectionStateLabel}
1026 environmentLabel={props.environmentLabel}
1027 selectedThread={props.selectedThread}
1028 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
1029 serverConfig={props.serverConfig}
1030 queueCount={props.selectedThreadQueueCount}
1031 environmentId={props.environmentId}
1032 projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
1033 // Follow-ups typed during setup wait in the draft: queueing
1034 // them against a thread id the server may still reject
1035 // would strand them in the outbox.
1036 sendBlockedReason={
1037 props.creationState?.kind === "preparing" ? "Starting the task…" : null
1038 }
1039 bottomInset={composerBottomInset}
1040 onChangeDraftMessage={props.onChangeDraftMessage}
1041 onPickDraftMedia={props.onPickDraftMedia}
1042 onPickDraftFiles={props.onPickDraftFiles}
1043 onNativePasteImages={props.onNativePasteImages}
1044 onRemoveDraftImage={props.onRemoveDraftImage}
1045 onStopThread={props.onStopThread}
1046 onSendMessage={handleSendMessage}
1047 onShowUsageLimits={showUsageLimits}
1048 onUpdateModelSelection={props.onUpdateThreadModelSelection}
1049 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
1050 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
1051 onExpandedChange={setComposerExpanded}
1052 onEditorFocusChange={handleComposerFocusChange}
1053 />
1054 </GlassBlurTargetContext>
10411055 </View>
10421056 </View>
10431057 </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);