Skip to content

feat(mobile): light/dark theme toggle - #527

Merged
Miracle656 merged 3 commits into
Miracle656:mainfrom
Y33t-dev:feat/mobile-theme-toggle
Jul 28, 2026
Merged

feat(mobile): light/dark theme toggle#527
Miracle656 merged 3 commits into
Miracle656:mainfrom
Y33t-dev:feat/mobile-theme-toggle

Conversation

@Y33t-dev

Copy link
Copy Markdown
Contributor

closes #490

Ports the web wallet's useTheme hook and ThemeToggle button to React Native. The choice persists under the same veil_theme storage key the web wallet uses, and the whole app restyles.

Translating the web approach

frontend/wallet/hooks/useTheme.ts applies its theme by setting data-theme on the document root and letting CSS variables do the rest. Neither half of that exists in React Native — no document, no cascading variables — so the palette becomes data instead:

export type ThemeColors = {
  background: string;
  surface: string;
  border: string;
  textStrong: string;
  // …
};

export const THEMES: Record<Theme, ThemeColors> = { dark: {}, light: {} };

Screens build their stylesheets from it:

const { colors } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);

The dark values are the ones the app already shipped with, lifted verbatim out of the existing screens. Dark mode is therefore pixel-identical to what is on main today, and light mode is the only new surface.

The token set is deliberately small — one per job (textStrong, textPrimary, textSecondary, textMuted, textFaint, …) rather than one per screen — so adding a screen does not mean adding tokens.

No provider

The active theme lives in module state read through useSyncExternalStore, not a context provider. That keeps useTheme() callable from anywhere with nothing to wrap the tree in, which is the ergonomic the web hook has, while still driving every subscriber from one source of truth. (On the web each useTheme call has its own useState and they stay in sync only because they all write to the same DOM attribute — that trick has no equivalent here, so the store is explicit.)

The snapshot handed to useSyncExternalStore is the theme name, not the colour object: snapshots are compared by identity, and a string compares by value. colors is then derived from a stable module constant, so it keeps a stable identity for as long as the theme does and is safe as a useMemo dependency.

The toggle actually restyles the app

hooks/useTheme.ts and components/ThemeToggle.tsx alone would be a switch that changes one button, so this also converts the existing screens:

  • app/_layout.tsx — themed StatusBar, plus contentStyle on the Stack so the background is painted behind every route. Without it a screen still loading, or shorter than the viewport, flashes the opposite theme.
  • app/index.tsx, app/swap.tsx, app/bulk-payout.tsx — static StyleSheet.create calls become createStyles(colors).

After the change there is not a single hardcoded colour literal left in app/. A test enforces the spirit of that: it asserts every colour role differs between the two themes, with onAccent as the one documented exception (white in both, because it sits on the accent fill rather than the background). A role accidentally given the same value in both would fail.

swap.tsx's Row helper now takes the stylesheet as a prop rather than closing over a module-level one — defining it inside the component would remount its subtree on every theme change.

Icons

The sun and moon are the web component's SVG paths, redrawn with react-native-svg (pinned to the version Expo SDK 57 bundles, so it works in Expo Go without a custom dev client). Behaviour matches the web control: it shows the icon of the mode you would switch to.

Failure handling

A tap applies immediately and persists in the background. If the write fails the user keeps the theme they just chose and loses only the preference on next launch — the opposite ordering would mean a storage hiccup makes a toggle appear broken. There is a test for that ordering.

Unreadable storage and corrupt stored values both fall back to dark rather than failing.

Files

  • lib/theme.ts — palette, active theme, persistence, subscription.
  • hooks/useTheme.ts — React binding.
  • components/ThemeToggle.tsx — the button.
  • app/_layout.tsx, app/index.tsx, app/swap.tsx, app/bulk-payout.tsx — restyled.
  • lib/__tests__/theme.test.ts — 18 tests.

Notes for review

  • The mobile app had no test harness, so this adds jest-expo. feat(mobile): encrypted wallet backup export #524 and feat(mobile): switch between testnet and mainnet at runtime #526 add the same harness independently, since all three need it and none depends on the others — whichever merges last can drop those lines. types: ["jest"] in tsconfig.json is the same story; without it TypeScript 6 does not pick up @types/jest under the Expo base config.
  • Following the OS appearance is deliberately not included. The issue asks for a light/dark switch, and the web hook it ports has two states; adding a third system option would diverge from the web wallet's model rather than port it. app.json already sets userInterfaceStyle: "automatic", so seeding the initial theme from useColorScheme() would be a small follow-up if wanted.
  • Screens added by other in-flight PRs still carry literal colours. They are separate files so nothing conflicts, but they will want the same createStyles(colors) treatment once both have landed.

Verification

$ npm run typecheck
(clean)

$ npm test
Tests: 18 passed, 18 total

Ports the web wallet's useTheme hook and ThemeToggle button to React
Native, persisting the choice under the same veil_theme storage key.

The web version applies its theme by setting data-theme on the document
root and letting CSS variables restyle everything. React Native has
neither a document nor cascading variables, so the palette becomes data:
lib/theme.ts defines a ThemeColors record per theme and screens build
their StyleSheets from it. The dark values are the ones the app already
shipped with, so dark mode looks exactly as it did and only light mode
is new.

The active theme lives in module state read through
useSyncExternalStore rather than a context provider, which keeps
useTheme() callable anywhere with nothing to wrap the tree in — the same
ergonomics as the web hook — while still driving every subscriber from
one source of truth.

Restyles the existing screens (home, swap, bulk payout) and the root
layout, so the toggle changes the whole app rather than one component.
The Stack's contentStyle carries the background so a route that is still
loading never flashes the opposite theme.

A tap applies immediately and persists in the background: a failed write
costs the preference on next launch rather than the interaction now.
@Y33t-dev
Y33t-dev requested a review from Miracle656 as a code owner July 28, 2026 01:10
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

@Y33t-dev is attempting to deploy a commit to the miracle656's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Jul 28, 2026

Copy link
Copy Markdown

@Y33t-dev Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656

Copy link
Copy Markdown
Owner

Thanks @Y33t-dev — light/dark theme toggle with useTheme + ThemeToggle, persisted to AsyncStorage under the same veil_theme key the web wallet uses, and the existing screens restyled to read from it. README section documenting the pattern is a nice touch. Closes #490. Merging.

# Conflicts:
#	frontend/mobile/README.md
#	frontend/mobile/package-lock.json
#	frontend/mobile/package.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

62. Theme toggle

3 participants