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 (
+
+ );
+}
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' ? (
+
+
+
+ ) : (
+
+ )}