src/lib/preferences.tsx
Global user-preference state (theme, amount format, toast density, toast duration, quiet mode) backed by localStorage. Used across the escrow payout display in MilestonesList and any component that needs to format monetary amounts.
Mount once at the app root (e.g. app/layout.tsx). Loads persisted preferences from localStorage on mount and writes back on every change.
<PreferencesProvider>{children}</PreferencesProvider>Returns { preferences, updatePreference, formatAmount }.
| Field | Type | Description |
|---|---|---|
preferences |
UserPreferences |
Current values |
updatePreference(key, value) |
function |
Merge-update one field and persist |
formatAmount(amount, currency?) |
function |
Format a number per amountFormat |
amountFormat |
Behaviour |
|---|---|
'usd' (default) |
Intl.NumberFormat en-US, currency style, passed currency (default "USD") |
'ngn' |
Intl.NumberFormat en-NG, currency forced to "NGN" |
'compact' |
en-US, compact notation, currency style with passed currency |
Edge cases handled: 0, fractions (e.g. 0.5), large payouts (1 000 000+), negative amounts.
type Theme = 'light' | 'dark' | 'system';
type AmountFormat = 'usd' | 'ngn' | 'compact';
type ToastDensity = 'relaxed' | 'compact';
type ToastDuration = 'short' | 'normal' | 'long' | 'persistent';
interface UserPreferences {
theme: Theme;
amountFormat: AmountFormat;
toastDensity: ToastDensity;
quietMode: boolean;
toastDuration: ToastDuration;
}'use client';
import { usePreferences } from '@/lib/preferences';
export function PayoutAmount({ amount, currency }: { amount: number; currency: string }) {
const { formatAmount } = usePreferences();
return <span>{formatAmount(amount, currency)}</span>;
}- No interactive UI is provided by this module; it is a context/hook layer only.
- Components consuming
formatAmountmust wrap output in appropriate ARIA text — e.g.<span aria-label="Payout $1,000.00">$1,000.00</span>.
User preferences are persisted to localStorage and therefore are reachable by
anything running in the page origin (including browser extensions, shared
kiosks, or a previous tenant of a shared browser). To keep the hydrated state
trustworthy, PreferencesProvider routes every read through the
sanitizePreferences(raw: unknown): UserPreferences helper before handing the
result to React state.
sanitizePreferences is a pure, total function that:
- Rejects non-object payloads (
null, primitives, arrays) and returns a fresh copy ofDEFAULT_PREFERENCESfor them. - Iterates only the source's own enumerable keys (
Object.keys) so keys inherited from a hostile prototype cannot reach the merge step. - Drops
__proto__,constructor, andprototypekeys outright — keys historically used to hijack prototypes via shallow merges. - Whitelists exactly
{ theme, amountFormat, toastDensity, quietMode, toastDuration }and validates each candidate value against its allowed set:theme∈'light' | 'dark' | 'system'amountFormat∈'usd' | 'ngn' | 'compact'toastDensity∈'relaxed' | 'compact'toastDuration∈'short' | 'normal' | 'long' | 'persistent'quietModemust be a literalboolean(not truthy coercibles like1,'true', or objects).
- Falls back to
DEFAULT_PREFERENCESfor any invalid or unknown value — the hydrating effect then composes{ ...DEFAULT_PREFERENCES, ...sanitized }, which is safe by construction becauseDEFAULT_PREFERENCEScarries every known key with a verified value.
The original try / catch is preserved, so a malformed JSON string still
falls back to defaults rather than throwing.
| Threat | Mitigation |
|---|---|
Tampered localStorage value with unknown keys |
Whitelisting — unknown keys are silently dropped |
| Invalid enum values driving rendering | Per-field allow-list validation |
__proto__ pollution via spread/Object.assign |
Explicit rejection of __proto__ during sanitization |
constructor / prototype pollution |
Explicit rejection of these dangerous key names |
quietMode truthy coercion (1, "true") |
Strict typeof === 'boolean' check |
Invalid toastDuration string |
Allow-list check; falls back to 'normal' (5 000 ms) |
| Inherited keys on attacker objects | Object.keys enumerates own enumerable keys only |
| Non-object payloads (arrays, primitives, null) | Early-return with DEFAULT_PREFERENCES |
- The sanitizer is exported so it can be unit-tested in isolation.
- Re-saving sanitized preferences back to
localStorageguarantees the stored payload contains only the five known keys, so a corrupt value can eventually self-heal once the user changes any preference.
File: src/lib/__tests__/preferences.test.tsx
Coverage targets (≥ 95%):
| Area | Tests |
|---|---|
| Default preferences | provides default preferences |
| localStorage read | loads preferences from localStorage on mount, merges partial data with defaults, falls back on invalid JSON |
| localStorage write | updates preferences and persists |
formatAmount – USD |
zero, fraction, large, negative, default currency |
formatAmount – NGN |
typical, zero, large |
formatAmount – compact |
thousands (K), millions (M), zero |
| Custom currency handling | default and compact formats preserve caller-provided currency |
| Re-render consumer | consumer component re-renders with updated format |
| Outside provider | default fallback formatting and no-op updatePreference |
Run tests:
npm test -- --testPathPattern=preferences --coveragesrc/components/ThemeToggle.tsx
One-click header button that toggles between light and dark themes. Uses the same updatePreference('theme', ...) call as SettingsPanel, so the two stay in sync.
| Current theme | Button action | Result |
|---|---|---|
'light' |
click | sets 'dark' |
'dark' |
click | sets 'light' |
'system' |
click | sets 'dark' (gives the user an explicit state) |
'system' remains available via Settings → Appearance → system.
The component renders null until its useEffect fires (mounted guard), preventing hydration mismatch. No flash of incorrect icon occurs because the PreferencesProvider also waits for isHydrated.
aria-labelreflects the next action:"Switch to dark theme"/"Switch to light theme".aria-pressedreflects the current dark state (truewhen dark,falseotherwise).- Inherits project focus-ring via
focus-visible:ring-2 focus-visible:ring-[var(--primary)].
Mount once in the app header (already done in src/app/layout.tsx):
import { ThemeToggle } from '@/components/ThemeToggle';
<div className="flex items-center gap-2">
<ThemeToggle />
<WalletConnectButton />
</div>File: src/components/__tests__/ThemeToggle.test.tsx
| Test | Description |
|---|---|
| SSR guard | button present after mount |
| Light label/icon | moon icon, aria-label "Switch to dark theme", aria-pressed false |
| Dark label/icon | sun icon, aria-label "Switch to light theme", aria-pressed true |
| light → dark | click updates preference to dark |
| dark → light | click updates preference to light |
| system → dark | first click from system sets dark |
| aria-pressed | reflects dark state after toggle |
| localStorage | toggled value persisted |