diff --git a/docs/unistyles.md b/docs/unistyles.md
index 4a9e179bb..e2a93de82 100644
--- a/docs/unistyles.md
+++ b/docs/unistyles.md
@@ -80,6 +80,34 @@ The important detail: the automatic native path tracks `props.style`. It does no
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
+## Dynamic Pixel Styles On Web
+
+Avoid feeding changing pixel values such as `{ top, left }`, `{ maxHeight }`, or `{ minWidth }` into the `style` prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to `#unistyles-web`; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth.
+
+Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers.
+
+## Inline Style Escape Hatch
+
+When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with `inlineUnistylesStyle`.
+
+```tsx
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
+
+const styles = StyleSheet.create({
+ thumb: {
+ position: "absolute",
+ },
+});
+
+;
+```
+
+This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary.
+
+Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes.
+
+When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
+
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
diff --git a/package-lock.json b/package-lock.json
index b8cc87493..e4dbe0819 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31040,17 +31040,15 @@
}
},
"node_modules/react-native-unistyles": {
- "version": "3.0.24",
- "resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.0.24.tgz",
- "integrity": "sha512-JWwkoXGSrxSzTBphBx1oYHTGQRyI7fup29D8kpjFdKqBLEXoOA1SzBB8phHb51kC4dCawaP1iOuyXoYm0kyCvw==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz",
+ "integrity": "sha512-S0aXJKZ6zYUViKMol+3on707AWD71fnAM7kmcHjk3FALy5QJicCCzxuluR0IptFQYNUP2dRqnnhgoS1X/U6Jpw==",
"license": "MIT",
- "workspaces": [
- "example",
- "docs",
- "expo-example"
- ],
+ "dependencies": {
+ "@babel/types": "7.29.0"
+ },
"engines": {
- "node": ">= 18.0.0"
+ "node": ">= 20.18.0"
},
"peerDependencies": {
"@react-native/normalize-colors": "*",
@@ -31061,6 +31059,9 @@
"react-native-reanimated": "*"
},
"peerDependenciesMeta": {
+ "react-native-edge-to-edge": {
+ "optional": true
+ },
"react-native-reanimated": {
"optional": true
}
@@ -36562,7 +36563,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
- "react-native-unistyles": "^3.0.15",
+ "react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
diff --git a/packages/app/package.json b/packages/app/package.json
index 5eabb6ef4..81693ed53 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -90,7 +90,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
- "react-native-unistyles": "^3.0.15",
+ "react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx
index bf4db0f4b..0c2d24c56 100644
--- a/packages/app/src/components/diff-viewer.tsx
+++ b/packages/app/src/components/diff-viewer.tsx
@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine } from "@/utils/tool-call-parsers";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -95,20 +96,27 @@ export function DiffViewer({
const outerScrollStyle = React.useMemo(
() => [
styles.verticalScroll,
- maxHeight !== undefined && { maxHeight },
+ maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
],
[maxHeight, fillAvailableHeight, webScrollbarStyle],
);
const linesContainerStyle = React.useMemo(
- () => [styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }],
+ () => [
+ styles.linesContainer,
+ scrollViewWidth > 0 && inlineUnistylesStyle({ minWidth: scrollViewWidth }),
+ ],
[scrollViewWidth],
);
const keyedDiffLines = React.useMemo(
() => diffLines.map((line, index) => ({ key: `${index}-${line.type}-${line.content}`, line })),
[diffLines],
);
+ const webVerticalContentStyle = React.useMemo(
+ () => [styles.verticalContent, fillAvailableHeight && styles.fillHeight],
+ [fillAvailableHeight],
+ );
if (!diffLines.length) {
return (
@@ -118,29 +126,39 @@ export function DiffViewer({
);
}
- return (
+ const lines = (
+
+ {keyedDiffLines.map(({ key, line }) => (
+
+ ))}
+
+ );
+
+ const horizontalScroll = (
+
+ {lines}
+
+ );
+
+ const content = (
-
-
- {keyedDiffLines.map(({ key, line }) => (
-
- ))}
-
-
+ {horizontalScroll}
);
+
+ return content;
}
const styles = StyleSheet.create((theme) => {
diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx
index 1bdb42426..a4e71a3d5 100644
--- a/packages/app/src/components/file-pane.tsx
+++ b/packages/app/src/components/file-pane.tsx
@@ -17,6 +17,7 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
@@ -120,7 +121,10 @@ const CodeLine = React.memo(function CodeLine({
gutterWidth,
highlighted,
}: CodeLineProps) {
- const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
+ const gutterStyle = useMemo(
+ () => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
+ [gutterWidth],
+ );
const lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],
diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx
index 338571804..bad25a0c1 100644
--- a/packages/app/src/components/tool-call-details.tsx
+++ b/packages/app/src/components/tool-call-details.tsx
@@ -13,6 +13,7 @@ import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -79,7 +80,7 @@ function useDetailStyles(
const codeVerticalScrollStyle = useMemo(
() => [
styles.codeVerticalScroll,
- resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
+ resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
@@ -88,7 +89,7 @@ function useDetailStyles(
const scrollAreaFillStyle = useMemo(
() => [
styles.scrollArea,
- resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
+ resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
@@ -97,7 +98,7 @@ function useDetailStyles(
const scrollAreaStyle = useMemo(
() => [
styles.scrollArea,
- resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
+ resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
webScrollbarStyle,
],
[resolvedMaxHeight, webScrollbarStyle],
diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx
index 1a1794963..6bc959ff1 100644
--- a/packages/app/src/components/ui/combobox.tsx
+++ b/packages/app/src/components/ui/combobox.tsx
@@ -20,6 +20,8 @@ import {
useWindowDimensions,
type LayoutChangeEvent,
type PressableStateCallbackType,
+ type StyleProp,
+ type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -56,6 +58,7 @@ import {
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
+import { FloatingSurface } from "@/components/ui/floating";
const IS_WEB = isWeb;
@@ -858,7 +861,7 @@ interface DesktopContainerStyleInput {
availableHeight: number | undefined;
}
-function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
+function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp {
const {
desktopMinWidth,
referenceWidth,
@@ -877,7 +880,6 @@ function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
: null;
return [
- styles.desktopContainer,
{
position: "absolute" as const,
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
@@ -1070,7 +1072,7 @@ interface DesktopBodyProps {
handleClose: () => void;
refs: ReturnType["refs"];
shouldUseDesktopFade: boolean;
- desktopContainerStyle: unknown;
+ desktopFrameStyle: StyleProp;
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
header: SheetHeader | undefined;
stickyHeader: ReactNode;
@@ -1191,11 +1193,12 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
>
-
)}
-
+
);
@@ -1492,9 +1495,9 @@ export function Combobox({
[theme.colors.palette.zinc],
);
- const desktopContainerStyle = useMemo(
+ const desktopFrameStyle = useMemo(
() =>
- buildDesktopContainerStyle({
+ buildDesktopFrameStyle({
desktopMinWidth,
referenceWidth,
desktopFixedHeight,
@@ -1561,7 +1564,7 @@ export function Combobox({
handleClose={handleClose}
refs={refs}
shouldUseDesktopFade={shouldUseDesktopFade}
- desktopContainerStyle={desktopContainerStyle}
+ desktopFrameStyle={desktopFrameStyle}
handleDesktopContentLayout={handleDesktopContentLayout}
header={header}
stickyHeader={stickyHeader}
diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx
index 22dcb1f53..dd75578c2 100644
--- a/packages/app/src/components/ui/context-menu.tsx
+++ b/packages/app/src/components/ui/context-menu.tsx
@@ -18,7 +18,6 @@ import {
Modal,
Platform,
Pressable,
- ScrollView,
StatusBar,
Text,
View,
@@ -28,7 +27,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
-import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
+import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native";
@@ -38,6 +37,7 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
+import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -498,7 +498,7 @@ export function ContextMenuContent({
[],
);
- const animatedContentStyle = useMemo(() => {
+ const frameStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
@@ -508,7 +508,6 @@ export function ContextMenuContent({
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
- styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
@@ -566,23 +565,24 @@ export function ContextMenuContent({
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
-
-
{children}
-
-
+
+
);
diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx
index 49c80e7e7..a51f92282 100644
--- a/packages/app/src/components/ui/dropdown-menu.tsx
+++ b/packages/app/src/components/ui/dropdown-menu.tsx
@@ -14,7 +14,6 @@ import {
ActivityIndicator,
Modal,
Pressable,
- ScrollView,
Text,
View,
Dimensions,
@@ -25,10 +24,10 @@ import {
type ViewStyle,
type StyleProp,
} from "react-native";
-import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
+import { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
-import { isWeb } from "@/constants/platform";
+import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -172,42 +171,37 @@ function computePosition({
return { x, y, actualPlacement };
}
-interface SharedDropdownContentProps {
- collapsable: false;
- testID?: string;
- style: StyleProp;
-}
-
function renderDropdownSurface(input: {
- isWebSurface: boolean;
- sharedProps: SharedDropdownContentProps;
+ frameStyle: StyleProp;
+ testID?: string;
+ surfaceStyle: StyleProp;
scrollable: boolean;
scrollViewportStyle: StyleProp;
content: ReactElement;
onExited: () => void;
}): ReactElement {
- const { isWebSurface, sharedProps, scrollable, scrollViewportStyle, content, onExited } = input;
+ const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } =
+ input;
const body = scrollable ? (
-
{content}
-
+
) : (
content
);
- if (isWebSurface) {
- return {body};
- }
-
return (
- {
"worklet";
@@ -217,7 +211,7 @@ function renderDropdownSurface(input: {
})}
>
{body}
-
+
);
}
@@ -513,7 +507,8 @@ export function DropdownMenuContent({
[],
);
- const contentStyle = useMemo(() => {
+ const surfaceStyle = styles.content;
+ const frameStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
@@ -523,7 +518,6 @@ export function DropdownMenuContent({
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
- styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
@@ -543,14 +537,6 @@ export function DropdownMenuContent({
actualPlacement,
align,
]);
- const sharedContentProps = useMemo(
- () => ({
- collapsable: false as const,
- testID,
- style: contentStyle,
- }),
- [testID, contentStyle],
- );
const scrollViewportStyle = useMemo(
() => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize, webScrollbarStyle],
@@ -583,8 +569,9 @@ export function DropdownMenuContent({
/>
{!closing
? renderDropdownSurface({
- isWebSurface: isWeb,
- sharedProps: sharedContentProps,
+ frameStyle,
+ testID,
+ surfaceStyle,
scrollable,
scrollViewportStyle,
content,
diff --git a/packages/app/src/components/ui/floating.tsx b/packages/app/src/components/ui/floating.tsx
new file mode 100644
index 000000000..4a689d7be
--- /dev/null
+++ b/packages/app/src/components/ui/floating.tsx
@@ -0,0 +1,63 @@
+import { forwardRef, useMemo, type ComponentProps, type ReactElement, type ReactNode } from "react";
+import {
+ ScrollView,
+ StyleSheet,
+ type ScrollViewProps,
+ type StyleProp,
+ type View,
+ type ViewStyle,
+} from "react-native";
+import Animated from "react-native-reanimated";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
+
+export interface FloatingSurfaceProps extends Omit, "style"> {
+ frameStyle?: StyleProp;
+ style?: StyleProp;
+}
+
+export const FloatingSurface = forwardRef(function FloatingSurface(
+ { frameStyle, style, ...props },
+ ref,
+): ReactElement {
+ const inlineFrameStyle = useMemo(() => {
+ const flattened = StyleSheet.flatten(frameStyle);
+ return flattened ? inlineUnistylesStyle(flattened) : undefined;
+ }, [frameStyle]);
+ const surfaceStyle = useMemo(() => [style, inlineFrameStyle], [inlineFrameStyle, style]);
+ return ;
+});
+
+export interface FloatingScrollViewProps {
+ bounces?: boolean;
+ children: ReactNode;
+ contentContainerStyle?: StyleProp;
+ keyboardShouldPersistTaps?: ScrollViewProps["keyboardShouldPersistTaps"];
+ showsVerticalScrollIndicator?: boolean;
+ style?: StyleProp;
+}
+
+export function FloatingScrollView({
+ bounces,
+ children,
+ contentContainerStyle,
+ keyboardShouldPersistTaps,
+ showsVerticalScrollIndicator,
+ style,
+}: FloatingScrollViewProps): ReactElement {
+ const inlineStyle = useMemo(() => {
+ const flattened = StyleSheet.flatten(style);
+ return flattened ? inlineUnistylesStyle(flattened) : undefined;
+ }, [style]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx
index 617c20746..4f403cce2 100644
--- a/packages/app/src/components/ui/tooltip.tsx
+++ b/packages/app/src/components/ui/tooltip.tsx
@@ -25,9 +25,10 @@ import {
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
-import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
+import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
+import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right";
@@ -494,19 +495,18 @@ export function TooltipContent({
[],
);
- const contentStyle = useMemo(
+ const frameStyle = useMemo(
() => [
- styles.content,
- { maxWidth },
- style,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
+ maxWidth,
},
],
- [maxWidth, style, position?.x, position?.y],
+ [maxWidth, position?.x, position?.y],
);
+ const contentStyle = useMemo(() => [styles.content, style], [style]);
const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]);
@@ -519,7 +519,7 @@ export function TooltipContent({
return (
-
{children}
-
+
);
@@ -544,7 +545,7 @@ export function TooltipContent({
onRequestClose={handleDismiss}
>
-
{children}
-
+
);
diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx
index 947e0c64f..caff521bc 100644
--- a/packages/app/src/components/web-desktop-scrollbar.tsx
+++ b/packages/app/src/components/web-desktop-scrollbar.tsx
@@ -1,17 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PanResponder,
- View,
+ type GestureResponderEvent,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
+ type ViewStyle,
+ View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { isWeb as platformIsWeb } from "@/constants/platform";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
-import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6;
@@ -27,6 +30,15 @@ const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const HANDLE_SCROLL_VISIBILITY_MS = 1200;
const HANDLE_SCROLL_ACTIVE_MS = 110;
+interface WebPointerStyle {
+ cursor?: "grab" | "grabbing";
+ touchAction?: "none";
+ userSelect?: "none";
+ transitionProperty?: string;
+ transitionDuration?: string;
+ transitionTimingFunction?: string;
+}
+
interface PointerLikeEvent {
clientY?: number;
pageY?: number;
@@ -59,6 +71,13 @@ function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
);
}
+interface WebDesktopScrollbarOverlayProps {
+ enabled: boolean;
+ metrics: ScrollbarMetrics;
+ onScrollToOffset: (offset: number) => void;
+ inverted?: boolean;
+}
+
export function useWebDesktopScrollbarMetrics() {
const [metrics, setMetrics] = useState({
offset: 0,
@@ -115,13 +134,6 @@ export function useWebDesktopScrollbarMetrics() {
};
}
-interface WebDesktopScrollbarOverlayProps {
- enabled: boolean;
- metrics: ScrollbarMetrics;
- onScrollToOffset: (offset: number) => void;
- inverted?: boolean;
-}
-
export function WebDesktopScrollbarOverlay({
enabled,
metrics,
@@ -273,8 +285,12 @@ export function WebDesktopScrollbarOverlay({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
- onPanResponderGrant: () => {
+ onPanResponderGrant: (event: GestureResponderEvent) => {
+ const clientY = readClientY(event);
dragStartOffsetRef.current = normalizedOffsetRef.current;
+ if (clientY !== null) {
+ dragStartClientYRef.current = clientY;
+ }
setIsDragging(true);
},
onPanResponderMove: (_event, gestureState) => {
@@ -362,20 +378,19 @@ export function WebDesktopScrollbarOverlay({
const thumbRegionStyle = useMemo(
() => [
styles.thumbRegion,
- {
- top: 0,
+ inlineUnistylesStyle({
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
- },
+ }),
platformIsWeb &&
- ({
+ inlineUnistylesStyle({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
- } as object),
+ } satisfies WebPointerStyle as unknown as ViewStyle),
],
[thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs],
);
@@ -383,19 +398,19 @@ export function WebDesktopScrollbarOverlay({
const handleStyle = useMemo(
() => [
styles.handle,
- {
+ inlineUnistylesStyle({
marginTop: handleInsetTop,
height: geometry.handleSize,
width: handleWidth,
backgroundColor: handleColor,
opacity: handleOpacity,
- },
+ }),
platformIsWeb &&
- ({
+ inlineUnistylesStyle({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
- } as object),
+ } satisfies WebPointerStyle as unknown as ViewStyle),
],
[handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity],
);
@@ -413,8 +428,6 @@ export function WebDesktopScrollbarOverlay({
{...(platformIsWeb
? ({
onPointerDown: startWebDrag,
- onPointerEnter: handleGrabHoverIn,
- onPointerLeave: handleGrabHoverOut,
onMouseEnter: handleGrabHoverIn,
onMouseLeave: handleGrabHoverOut,
} as object)
@@ -446,5 +459,6 @@ const styles = StyleSheet.create(() => ({
position: "absolute",
right: -3,
width: HANDLE_GRAB_WIDTH,
+ top: 0,
},
}));
diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx
index 1d89935a1..9c32fdd58 100644
--- a/packages/app/src/components/workspace-hover-card.tsx
+++ b/packages/app/src/components/workspace-hover-card.tsx
@@ -9,7 +9,7 @@ import {
type ReactNode,
} from "react";
import { Dimensions, Text, View } from "react-native";
-import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
+import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
@@ -24,6 +24,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
+import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
interface Rect {
@@ -254,23 +255,19 @@ function WorkspaceHoverCardContent({
[],
);
- const cardStyle = useMemo(
- () => [
- styles.card,
- {
- width: HOVER_CARD_WIDTH,
- position: "absolute" as const,
- top: position?.y ?? -9999,
- left: position?.x ?? -9999,
- },
- ],
+ const frameStyle = useMemo(
+ () => ({
+ position: "absolute" as const,
+ top: position?.y ?? -9999,
+ left: position?.x ?? -9999,
+ }),
[position?.x, position?.y],
);
return (
-
@@ -303,7 +301,7 @@ function WorkspaceHoverCardContent({
>
) : null}
-
+
);
@@ -441,6 +439,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
paddingTop: theme.spacing[2],
+ width: HOVER_CARD_WIDTH,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
diff --git a/packages/app/src/git/actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx
index 7fb73a05c..53ba6b98e 100644
--- a/packages/app/src/git/actions-split-button.tsx
+++ b/packages/app/src/git/actions-split-button.tsx
@@ -17,6 +17,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/git/policy";
@@ -110,18 +111,20 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
const primaryPressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.splitButtonPrimary,
- (Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
+ (Boolean(hovered) || pressed) &&
+ inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
primaryDisabled && styles.splitButtonPrimaryDisabled,
],
- [primaryDisabled],
+ [primaryDisabled, theme.colors.surface2],
);
const caretTriggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
styles.splitButtonCaret,
- (hovered || pressed || open) && styles.splitButtonCaretHovered,
+ (hovered || pressed || open) &&
+ inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
],
- [],
+ [theme.colors.surface2],
);
return (
@@ -230,9 +233,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
position: "relative",
},
- splitButtonPrimaryHovered: {
- backgroundColor: theme.colors.surface2,
- },
splitButtonPrimaryDisabled: {
opacity: 0.6,
},
@@ -258,9 +258,6 @@ const styles = StyleSheet.create((theme) => ({
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
- splitButtonCaretHovered: {
- backgroundColor: theme.colors.surface2,
- },
iconButton: {
width: 32,
height: 32,
diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx
index c312ebdb2..ed9de619e 100644
--- a/packages/app/src/git/diff-pane.tsx
+++ b/packages/app/src/git/diff-pane.tsx
@@ -78,6 +78,7 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
@@ -113,25 +114,6 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
}
-function diffModeTriggerStyle({
- hovered,
- pressed,
- open,
-}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
- return [
- styles.diffModeTrigger,
- Boolean(hovered) && styles.diffModeTriggerHovered,
- (pressed || Boolean(open)) && styles.diffModeTriggerPressed,
- ];
-}
-
-function expandAllButtonStyle({
- hovered,
- pressed,
-}: PressableStateCallbackType & { hovered?: boolean }) {
- return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered];
-}
-
interface HighlightedTextProps {
tokens: HighlightToken[];
wrapLines?: boolean;
@@ -274,7 +256,12 @@ function DiffGutterCell({
style?: StyleProp;
}) {
const containerStyle = useMemo(
- () => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }, style],
+ () => [
+ styles.gutterCell,
+ lineTypeBackground(type),
+ inlineUnistylesStyle({ width: gutterWidth }),
+ style,
+ ],
[type, gutterWidth, style],
);
const textStyle = useMemo(
@@ -544,7 +531,10 @@ function InlineReviewThreadContent({
}) {
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
- const placeholderStyle = useMemo(() => ({ minHeight: height }), [height]);
+ const placeholderStyle = useMemo(
+ () => inlineUnistylesStyle({ minHeight: height }),
+ [height],
+ );
if (height === 0) {
return null;
}
@@ -580,7 +570,11 @@ function InlineReviewGutterSpacer({
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const spacerStyle = useMemo>(
- () => [styles.inlineReviewGutterSpacer, { width: gutterWidth, minHeight: height }, style],
+ () => [
+ styles.inlineReviewGutterSpacer,
+ inlineUnistylesStyle({ width: gutterWidth, minHeight: height }),
+ style,
+ ],
[gutterWidth, height, style],
);
if (height === 0) {
@@ -604,10 +598,13 @@ function InlineReviewRow({
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const gutterSpacerStyle = useMemo>(
- () => [styles.inlineReviewGutterSpacer, { width: gutterWidth }],
+ () => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
- const placeholderStyle = useMemo(() => ({ minHeight: height }), [height]);
+ const placeholderStyle = useMemo(
+ () => inlineUnistylesStyle({ minHeight: height }),
+ [height],
+ );
if (height === 0) {
return null;
}
@@ -656,7 +653,10 @@ function SplitDiffColumn({
[showDivider],
);
const linesContainerRowStyle = useMemo(
- () => [styles.linesContainer, scrollWidth > 0 && { minWidth: scrollWidth }],
+ () => [
+ styles.linesContainer,
+ scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }),
+ ],
[scrollWidth],
);
@@ -916,7 +916,10 @@ function DiffFileBody({
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
const linesContainerRowStyle = useMemo(
- () => [styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }],
+ () => [
+ styles.linesContainer,
+ availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }),
+ ],
[availableWidth],
);
@@ -1062,7 +1065,7 @@ interface GitDiffPaneProps {
}
type PressableStyleFn = (
- state: PressableStateCallbackType & { hovered?: boolean },
+ state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp;
interface DiffLayoutToggleGroupProps {
@@ -1436,14 +1439,30 @@ function computePrErrorMessage(
return prPayloadError?.message ?? null;
}
+function buildDiffModeTriggerStyle(surfaceColor: string): PressableStyleFn {
+ return ({ hovered, pressed, open }) => [
+ styles.diffModeTrigger,
+ (Boolean(hovered) || pressed || Boolean(open)) &&
+ inlineUnistylesStyle({ backgroundColor: surfaceColor }),
+ ];
+}
+
+function buildExpandAllButtonStyle(surfaceColor: string): PressableStyleFn {
+ return ({ hovered, pressed }) => [
+ styles.expandAllButton,
+ (Boolean(hovered) || pressed) && inlineUnistylesStyle({ backgroundColor: surfaceColor }),
+ ];
+}
+
function buildToggleButtonStyle(
selected: boolean,
baseStyles: StyleProp | StyleProp[],
+ surfaceColor: string,
): PressableStyleFn {
return ({ hovered, pressed }) => [
baseStyles,
- selected && styles.toggleButtonSelected,
- (Boolean(hovered) || pressed) && styles.diffStatusRowHovered,
+ (selected || Boolean(hovered) || pressed) &&
+ inlineUnistylesStyle({ backgroundColor: surfaceColor }),
];
}
@@ -1494,32 +1513,50 @@ export function GitDiffPane({
handleLayoutChange("split");
}, [handleLayoutChange]);
+ const controlSurfaceColor = theme.colors.surface2;
+ const diffModeTriggerStyle = useMemo(
+ () => buildDiffModeTriggerStyle(controlSurfaceColor),
+ [controlSurfaceColor],
+ );
+
const unifiedToggleStyle = useMemo(
() =>
- buildToggleButtonStyle(changesPreferences.layout === "unified", [
- styles.toggleButton,
- styles.toggleButtonGroupStart,
- ]),
- [changesPreferences.layout],
+ buildToggleButtonStyle(
+ changesPreferences.layout === "unified",
+ [styles.toggleButton, styles.toggleButtonGroupStart],
+ controlSurfaceColor,
+ ),
+ [changesPreferences.layout, controlSurfaceColor],
);
const splitToggleStyle = useMemo(
() =>
- buildToggleButtonStyle(changesPreferences.layout === "split", [
- styles.toggleButton,
- styles.toggleButtonGroupEnd,
- ]),
- [changesPreferences.layout],
+ buildToggleButtonStyle(
+ changesPreferences.layout === "split",
+ [styles.toggleButton, styles.toggleButtonGroupEnd],
+ controlSurfaceColor,
+ ),
+ [changesPreferences.layout, controlSurfaceColor],
);
const hideWhitespaceToggleStyle = useMemo(
- () => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
- [changesPreferences.hideWhitespace],
+ () =>
+ buildToggleButtonStyle(
+ changesPreferences.hideWhitespace,
+ styles.expandAllButton,
+ controlSurfaceColor,
+ ),
+ [changesPreferences.hideWhitespace, controlSurfaceColor],
);
const wrapLinesToggleStyle = useMemo(
- () => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
- [wrapLines],
+ () => buildToggleButtonStyle(wrapLines, styles.expandAllButton, controlSurfaceColor),
+ [wrapLines, controlSurfaceColor],
+ );
+
+ const expandAllToggleStyle = useMemo(
+ () => buildExpandAllButtonStyle(controlSurfaceColor),
+ [controlSurfaceColor],
);
const {
@@ -2046,7 +2083,7 @@ export function GitDiffPane({
allExpanded={allExpanded}
isMobile={isMobile}
wrapLinesToggleStyle={wrapLinesToggleStyle}
- expandAllToggleStyle={expandAllButtonStyle}
+ expandAllToggleStyle={expandAllToggleStyle}
onToggleWrapLines={handleToggleWrapLines}
onToggleExpandAll={handleToggleExpandAll}
/>
diff --git a/packages/app/src/review/surface.test.tsx b/packages/app/src/review/surface.test.tsx
index 13e59f579..f773e63d4 100644
--- a/packages/app/src/review/surface.test.tsx
+++ b/packages/app/src/review/surface.test.tsx
@@ -3,6 +3,7 @@ import "@/test/window-local-storage";
import { act, fireEvent, render, renderHook, cleanup } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
@@ -275,7 +276,7 @@ describe("git diff inline review helpers", () => {
viewportWidth: 320,
pinToViewport: true,
}),
- ).toEqual([{ position: "sticky", left: 0 }, { width: 320 }]);
+ ).toEqual([{ position: "sticky", left: 0 }, inlineUnistylesStyle({ width: 320 })]);
});
it("keeps the gutter add-comment target accessible and clicking opens the editor", () => {
diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx
index d07133c0a..1ea505e38 100644
--- a/packages/app/src/review/surface.tsx
+++ b/packages/app/src/review/surface.tsx
@@ -15,6 +15,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { Shortcut } from "@/components/ui/shortcut";
import { isWeb } from "@/constants/platform";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
@@ -391,7 +392,7 @@ export function InlineReviewThread({
() => [
styles.threadContainer,
getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }),
- { minHeight: height },
+ inlineUnistylesStyle({ minHeight: height }),
],
[viewportWidth, pinToViewport, height],
);
@@ -478,7 +479,8 @@ export function getInlineReviewThreadViewportStyle({
viewportWidth?: number;
pinToViewport: boolean;
}): StyleProp {
- const widthStyle = viewportWidth && viewportWidth > 0 ? { width: viewportWidth } : null;
+ const widthStyle =
+ viewportWidth && viewportWidth > 0 ? inlineUnistylesStyle({ width: viewportWidth }) : null;
if (!pinToViewport || !isWeb) {
return widthStyle;
}
diff --git a/packages/app/src/styles/unistyles-inline-style.ts b/packages/app/src/styles/unistyles-inline-style.ts
new file mode 100644
index 000000000..4201c2b0d
--- /dev/null
+++ b/packages/app/src/styles/unistyles-inline-style.ts
@@ -0,0 +1,23 @@
+const UNISTYLES_INLINE_STYLE_KEY = "unistyles_inline_style";
+
+/**
+ * Forces a style object through Unistyles' inline/animated-style lane.
+ *
+ * Unistyles web sends ordinary style objects to the CSS registry. Styles that
+ * look like animated styles stay in React Native's style array instead. Use
+ * this only for high-churn values, such as measured dimensions, drag
+ * transforms, and pressed/hovered state.
+ */
+export function inlineUnistylesStyle(style: TStyle): TStyle {
+ if (!Object.isExtensible(style) || UNISTYLES_INLINE_STYLE_KEY in style) {
+ return style;
+ }
+
+ Object.defineProperty(style, UNISTYLES_INLINE_STYLE_KEY, {
+ value: {},
+ enumerable: true,
+ configurable: true,
+ });
+
+ return style;
+}