From 5f8d21f09cd7f7a6b6df5b0e29a95a9552115507 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:07:26 -0300 Subject: [PATCH 1/3] feat(rn_cli_wallet): desktop-web device frame + web layout fixes Constrain the web build to a phone-like framed surface on wide desktop viewports (>=1024px) instead of stretching full-width, plus related web-only layout/interaction fixes. - Add DesktopFrameWrapper (.web frame, native passthrough) wrapping the app root; centered ~430px surface with rounded corners + shadow on a neutral page background. Shared dims in constants/DesktopFrame.ts. - Header: add top padding on web (safe-area top is 0) so the logo/scan icon aren't flush to the top. - Tab bar (web): lighter theme-aware top divider + extra height/padding so labels aren't clipped in the frame. - Modal (web): cap sheet width to the frame and render inline (coverScreen=false) so it stays within the frame and clips to its rounded bottom instead of overflowing the viewport. - Settings: make the dark-mode Switch display-only (pointerEvents=none) so a tap flows to the card's single toggle; fixes web double-toggle where the switch click also bubbled to the card (net no change). Co-Authored-By: Claude Opus 4.8 --- .../src/components/DesktopFrameWrapper.tsx | 9 ++ .../components/DesktopFrameWrapper.web.tsx | 90 +++++++++++++++++++ .../rn_cli_wallet/src/components/Header.tsx | 7 +- .../rn_cli_wallet/src/components/Modal.tsx | 15 +++- .../src/constants/DesktopFrame.ts | 17 ++++ .../src/navigators/HomeTabNavigator.web.tsx | 11 ++- wallets/rn_cli_wallet/src/screens/App.tsx | 39 ++++---- .../src/screens/Settings/index.tsx | 31 ++++--- 8 files changed, 185 insertions(+), 34 deletions(-) create mode 100644 wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.tsx create mode 100644 wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.web.tsx create mode 100644 wallets/rn_cli_wallet/src/constants/DesktopFrame.ts diff --git a/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.tsx b/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.tsx new file mode 100644 index 00000000..7467579f --- /dev/null +++ b/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from 'react'; + +/** + * Native passthrough. The desktop frame only exists on web + * (see DesktopFrameWrapper.web.tsx); on iOS/Android the app renders full-screen. + */ +export function DesktopFrameWrapper({ children }: PropsWithChildren) { + return <>{children}; +} diff --git a/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.web.tsx b/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.web.tsx new file mode 100644 index 00000000..8a6daacf --- /dev/null +++ b/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.web.tsx @@ -0,0 +1,90 @@ +import { PropsWithChildren, useEffect, useState } from 'react'; +import { useSnapshot } from 'valtio'; + +import SettingsStore from '@/store/SettingsStore'; +import { DarkTheme, LightTheme } from '@/utils/ThemeUtil'; +import { DesktopFrame } from '@/constants/DesktopFrame'; + +/** + * "Framed-lite" desktop frame for web. + * + * On a wide desktop viewport (>= BREAKPOINT) the app is centered in a fixed, + * phone-width surface with rounded corners and a drop shadow so it doesn't + * stretch giant across the screen. Below the breakpoint (mobile web) it renders + * full-bleed, unchanged. + * + * Unlike the pos-app frame this deliberately avoids `transform: scale()` and a + * modal portal: rn_cli_wallet's and render as fixed + * full-viewport overlays and would fight a scaled frame. Those stay untouched. + */ +const RESIZE_DEBOUNCE_MS = 150; + +function useIsDesktopWeb(): boolean { + const [isDesktop, setIsDesktop] = useState( + () => + typeof window !== 'undefined' && + window.innerWidth >= DesktopFrame.BREAKPOINT, + ); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + let timeout: ReturnType | undefined; + const onResize = () => { + clearTimeout(timeout); + timeout = setTimeout(() => { + setIsDesktop(window.innerWidth >= DesktopFrame.BREAKPOINT); + }, RESIZE_DEBOUNCE_MS); + }; + + window.addEventListener('resize', onResize); + return () => { + clearTimeout(timeout); + window.removeEventListener('resize', onResize); + }; + }, []); + + return isDesktop; +} + +export function DesktopFrameWrapper({ children }: PropsWithChildren) { + const { themeMode } = useSnapshot(SettingsStore.state); + const isDesktop = useIsDesktopWeb(); + + if (!isDesktop) { + return <>{children}; + } + + const Theme = themeMode === 'dark' ? DarkTheme : LightTheme; + const backgroundColor = + themeMode === 'dark' ? DesktopFrame.BACKGROUND.dark : DesktopFrame.BACKGROUND.light; + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/wallets/rn_cli_wallet/src/components/Header.tsx b/wallets/rn_cli_wallet/src/components/Header.tsx index bf660a60..134eba74 100644 --- a/wallets/rn_cli_wallet/src/components/Header.tsx +++ b/wallets/rn_cli_wallet/src/components/Header.tsx @@ -53,7 +53,12 @@ export function Header() { style={[ styles.container, { - paddingTop: Platform.OS === 'ios' ? top : top + Spacing[2], + paddingTop: + Platform.OS === 'ios' + ? top + : Platform.OS === 'web' + ? Spacing[8] + : top + Spacing[2], backgroundColor: Theme['bg-primary'], }, ]} diff --git a/wallets/rn_cli_wallet/src/components/Modal.tsx b/wallets/rn_cli_wallet/src/components/Modal.tsx index b4b36a3a..7229fe82 100644 --- a/wallets/rn_cli_wallet/src/components/Modal.tsx +++ b/wallets/rn_cli_wallet/src/components/Modal.tsx @@ -7,6 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import ModalStore from '@/store/ModalStore'; import { Spacing } from '@/utils/ThemeUtil'; +import { DesktopFrame } from '@/constants/DesktopFrame'; import SessionProposalModal from '@/modals/SessionProposalModal'; import SessionSignModal from '@/modals/SessionSignModal'; import SessionSendTransactionModal from '@/modals/SessionSendTransactionModal'; @@ -100,7 +101,11 @@ export default function Modal() { statusBarTranslucent propagateSwipe onBackdropPress={onClose} - style={styles.modal} + style={[styles.modal, Platform.OS === 'web' ? styles.modalWeb : null]} + // On web, render inline (not via the full-screen portal) so the sheet + + // backdrop stay inside the desktop frame and get clipped to its rounded + // bottom corners instead of overflowing to the viewport edge. + coverScreen={Platform.OS !== 'web'} isVisible={open} > @@ -114,6 +119,14 @@ const styles = StyleSheet.create({ modal: { margin: Spacing[0], }, + // On web, constrain the sheet to the desktop frame width and center it so it + // doesn't stretch full-viewport. Self-adjusts: fills the width on narrow + // screens, caps + centers on wide desktop. Backdrop still dims the full page. + modalWeb: { + width: '100%', + maxWidth: DesktopFrame.DEVICE_WIDTH, + alignSelf: 'center', + }, gestureRoot: { flex: 1, margin: Spacing[0], diff --git a/wallets/rn_cli_wallet/src/constants/DesktopFrame.ts b/wallets/rn_cli_wallet/src/constants/DesktopFrame.ts new file mode 100644 index 00000000..f4aa157a --- /dev/null +++ b/wallets/rn_cli_wallet/src/constants/DesktopFrame.ts @@ -0,0 +1,17 @@ +// Shared dimensions for the "framed-lite" desktop-web layout. +// Used by DesktopFrameWrapper.web.tsx (the frame) and Modal.tsx (to keep sheets +// constrained to the same width on desktop web). +export const DesktopFrame = { + // Min viewport width at which the desktop frame is shown + BREAKPOINT: 1024, + // Phone-like surface width the app is constrained to + DEVICE_WIDTH: 430, + DEVICE_HEIGHT: 932, + SCREEN_RADIUS: 40, + SHADOW: '0 25px 50px -12px rgba(0, 0, 0, 0.25)', + // Neutral page background behind the frame (distinct from the screen bg for contrast) + BACKGROUND: { + light: '#F0F0F0', + dark: '#0A0A0A', + }, +} as const; diff --git a/wallets/rn_cli_wallet/src/navigators/HomeTabNavigator.web.tsx b/wallets/rn_cli_wallet/src/navigators/HomeTabNavigator.web.tsx index 1c9b3fdc..2225d582 100644 --- a/wallets/rn_cli_wallet/src/navigators/HomeTabNavigator.web.tsx +++ b/wallets/rn_cli_wallet/src/navigators/HomeTabNavigator.web.tsx @@ -9,6 +9,7 @@ import Wallets from '@/screens/Wallets'; import Connections from '@/screens/Connections'; import Settings from '@/screens/Settings'; import { useTheme } from '@/hooks/useTheme'; +import { Spacing } from '@/utils/ThemeUtil'; const TabNav = createBottomTabNavigator(); @@ -31,7 +32,15 @@ export function HomeTabNavigator() { screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: Theme['bg-primary'] }, - tabBarStyle: { backgroundColor: Theme['bg-primary'] }, + tabBarStyle: { + backgroundColor: Theme['bg-primary'], + // Softer, lighter top divider than the react-navigation default + borderTopColor: Theme['border-secondary'], + // Extra height + bottom padding so labels aren't clipped in the desktop frame + height: 72, + paddingBottom: Spacing[4], + paddingTop: Spacing[1], + }, }}> { ); return ( - - - - 'React N. Wallet' }}> - - - - - - - - - + + + + + 'React N. Wallet' }}> + + + + + + + + + + ); }; diff --git a/wallets/rn_cli_wallet/src/screens/Settings/index.tsx b/wallets/rn_cli_wallet/src/screens/Settings/index.tsx index 60a13a10..628de0a9 100644 --- a/wallets/rn_cli_wallet/src/screens/Settings/index.tsx +++ b/wallets/rn_cli_wallet/src/screens/Settings/index.tsx @@ -81,19 +81,24 @@ export default function Settings() { Dark mode - + {/* Display-only: the whole card (PressableScale) owns the toggle. + pointerEvents="none" lets a tap on the switch pass through to the + card's onPress, so it toggles exactly once. Without this, on web + the switch's own click also bubbles to the card and toggles twice + (net no change). */} + + + Date: Fri, 3 Jul 2026 12:47:05 -0300 Subject: [PATCH 2/3] refactor(rn_cli_wallet): narrow webAccentSwitchProps cast Address PR review: replace `as object` (which suppressed type checking on the whole Switch spread) with a narrow type so TS keeps validating. Co-Authored-By: Claude Opus 4.8 --- wallets/rn_cli_wallet/src/screens/Settings/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallets/rn_cli_wallet/src/screens/Settings/index.tsx b/wallets/rn_cli_wallet/src/screens/Settings/index.tsx index 628de0a9..e5c5ca1a 100644 --- a/wallets/rn_cli_wallet/src/screens/Settings/index.tsx +++ b/wallets/rn_cli_wallet/src/screens/Settings/index.tsx @@ -35,7 +35,7 @@ export default function Settings() { activeThumbColor: Theme.white, } : {} - ) as object; + ) as { activeTrackColor?: string; activeThumbColor?: string }; useEffect(() => { async function getAsyncData() { From 519988e0000de99234f1497072d22b2aa8f1ffbf Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:19:48 -0300 Subject: [PATCH 3/3] refactor(rn_cli_wallet): address Copilot review on web frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings: gate the display-only Switch (pointerEvents=none, no onValueChange) to web only; keep the Switch fully interactive and accessible on native, which never had the double-toggle bug. - Modal / DesktopFrameWrapper: correct stale comments — with coverScreen=false the web modal renders inline and its backdrop dims only the frame area, not the full page. Co-Authored-By: Claude Opus 4.8 --- .../components/DesktopFrameWrapper.web.tsx | 5 +++-- .../rn_cli_wallet/src/components/Modal.tsx | 3 ++- .../src/screens/Settings/index.tsx | 22 ++++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.web.tsx b/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.web.tsx index 8a6daacf..c99bbcff 100644 --- a/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.web.tsx +++ b/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.web.tsx @@ -14,8 +14,9 @@ import { DesktopFrame } from '@/constants/DesktopFrame'; * full-bleed, unchanged. * * Unlike the pos-app frame this deliberately avoids `transform: scale()` and a - * modal portal: rn_cli_wallet's and render as fixed - * full-viewport overlays and would fight a scaled frame. Those stay untouched. + * modal portal. On web the renders inline (coverScreen=false) so it + * stays inside this frame and is clipped by `overflow: hidden`; still + * renders as a fixed full-viewport overlay and is left untouched. */ const RESIZE_DEBOUNCE_MS = 150; diff --git a/wallets/rn_cli_wallet/src/components/Modal.tsx b/wallets/rn_cli_wallet/src/components/Modal.tsx index 7229fe82..01112829 100644 --- a/wallets/rn_cli_wallet/src/components/Modal.tsx +++ b/wallets/rn_cli_wallet/src/components/Modal.tsx @@ -121,7 +121,8 @@ const styles = StyleSheet.create({ }, // On web, constrain the sheet to the desktop frame width and center it so it // doesn't stretch full-viewport. Self-adjusts: fills the width on narrow - // screens, caps + centers on wide desktop. Backdrop still dims the full page. + // screens, caps + centers on wide desktop. With coverScreen=false the backdrop + // dims only the frame area (its parent), not the full page. modalWeb: { width: '100%', maxWidth: DesktopFrame.DEVICE_WIDTH, diff --git a/wallets/rn_cli_wallet/src/screens/Settings/index.tsx b/wallets/rn_cli_wallet/src/screens/Settings/index.tsx index e5c5ca1a..8e7f428e 100644 --- a/wallets/rn_cli_wallet/src/screens/Settings/index.tsx +++ b/wallets/rn_cli_wallet/src/screens/Settings/index.tsx @@ -81,14 +81,21 @@ export default function Settings() { Dark mode - {/* Display-only: the whole card (PressableScale) owns the toggle. - pointerEvents="none" lets a tap on the switch pass through to the - card's onPress, so it toggles exactly once. Without this, on web - the switch's own click also bubbles to the card and toggles twice - (net no change). */} - + {/* On web the whole card (PressableScale) owns the toggle: a tap on + the switch bubbles up to the card, so if the Switch also fired + onValueChange it would toggle twice (net no change). Render it + display-only with pointerEvents="none" so the tap passes through. + On native there's no double-toggle, so keep the Switch fully + interactive (and accessible) with its own onValueChange. */} + {Platform.OS === 'web' ? ( + + + + ) : ( - + )}