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..c99bbcff --- /dev/null +++ b/wallets/rn_cli_wallet/src/components/DesktopFrameWrapper.web.tsx @@ -0,0 +1,91 @@ +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. 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; + +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..01112829 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,15 @@ 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. With coverScreen=false the backdrop + // dims only the frame area (its parent), not 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..8e7f428e 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() { @@ -81,19 +81,30 @@ export default function Settings() { Dark mode - + {/* 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' ? ( + + + + ) : ( + + )}