Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
+136 -6
View File
@@ -1,11 +1,141 @@
import React, { createContext, type PropsWithChildren, useContext } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from 'react';
import { useColorScheme } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { darkTheme, type FintechTheme } from './tokens';
import { lightTheme, themes, type FintechTheme, type ThemeName } from './tokens';
const ThemeContext = createContext<FintechTheme>(darkTheme);
/**
* Theme selection, and the one place the app decides which ramp is live.
*
* ── THREE MODES, NOT TWO ────────────────────────────────────────────────────
* `system` is the default and is not the same as `light`. A customer who has
* put their handset in dark mode has already stated a preference, and an app
* that ignores it is the one bright rectangle in an otherwise dark evening.
* `light` and `dark` are explicit overrides for someone who wants this app to
* differ from their handset — which is a real preference, usually about
* legibility rather than taste.
*
* ── WHY THE CHOICE IS PERSISTED, AND WHY IT IS NOT IN REDUX ─────────────────
* The theme has to be readable before the first paint, and it is not
* transactional state: nothing else in the app derives from it, no reducer
* needs to see it change, and it must survive a reinstall of the store. So it
* lives in AsyncStorage under its own key and is read once, here.
*
* The first paint uses the SYSTEM scheme rather than blocking on that read.
* Blocking would mean a splash-shaped hole on every launch to serve a
* preference most people never set; using the system scheme means the only
* people who see a change after hydration are those who explicitly chose to
* differ from their own handset, which is a much smaller and much less
* jarring case than a flash of the wrong theme for everyone.
*/
export const ThemeProvider = ({ children }: PropsWithChildren) => (
<ThemeContext.Provider value={darkTheme}>{children}</ThemeContext.Provider>
);
const STORAGE_KEY = '@bankcbs/theme-mode';
/** What the customer chose. `system` follows the handset. */
export type ThemeMode = ThemeName | 'system';
const isThemeMode = (value: unknown): value is ThemeMode =>
value === 'light' || value === 'dark' || value === 'system';
type ThemeControls = {
/** What the customer chose — including `system`. */
mode: ThemeMode;
/** Which ramp that resolved to right now. Never `system`. */
resolved: ThemeName;
/** True once the persisted choice has been read. */
hydrated: boolean;
setMode: (mode: ThemeMode) => void;
/**
* Flips between light and dark. Deliberately resolves `system` to its
* opposite rather than cycling through three states: a toggle that
* sometimes does nothing visible — because `system` already matched the
* handset — reads as broken.
*/
toggle: () => void;
};
const ThemeContext = createContext<FintechTheme>(lightTheme);
const ThemeControlsContext = createContext<ThemeControls>({
mode: 'system',
resolved: 'light',
hydrated: false,
setMode: () => {},
toggle: () => {},
});
export const ThemeProvider = ({ children }: PropsWithChildren) => {
// Null on a handset with no preference set, and on some Android versions
// during the first frames — treated as light, which is this app's default.
const systemScheme = useColorScheme();
const [mode, setModeState] = useState<ThemeMode>('system');
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
let cancelled = false;
void AsyncStorage.getItem(STORAGE_KEY)
.then((stored) => {
// A cancelled read still marks hydrated: the provider has finished
// trying, and a consumer waiting on `hydrated` should not hang because
// the read resolved after unmount.
if (cancelled) return;
if (isThemeMode(stored)) setModeState(stored);
setHydrated(true);
})
.catch(() => {
// A failed read is not worth surfacing — the app simply follows the
// handset, which is the default anyway.
if (!cancelled) setHydrated(true);
});
return () => {
cancelled = true;
};
}, []);
const setMode = useCallback((next: ThemeMode) => {
// Applied immediately and persisted in the background: the toggle has to
// feel instant, and a failed write costs the preference at next launch,
// not the tap.
setModeState(next);
void AsyncStorage.setItem(STORAGE_KEY, next).catch(() => {});
}, []);
const resolved: ThemeName = mode === 'system' ? (systemScheme === 'dark' ? 'dark' : 'light') : mode;
const toggle = useCallback(() => {
setMode(resolved === 'dark' ? 'light' : 'dark');
}, [resolved, setMode]);
const controls = useMemo<ThemeControls>(
() => ({ mode, resolved, hydrated, setMode, toggle }),
[mode, resolved, hydrated, setMode, toggle],
);
return (
<ThemeControlsContext.Provider value={controls}>
<ThemeContext.Provider value={themes[resolved]}>{children}</ThemeContext.Provider>
</ThemeControlsContext.Provider>
);
};
/** The live theme. This is what screens use. */
export const useThemeContext = () => useContext(ThemeContext);
/**
* The mode and the setters — for a settings screen or a toggle.
*
* Separate from `useTheme()` on purpose: a screen that only paints itself
* should not re-render when the mode object changes identity, and a toggle
* should not have to reach through the whole theme to find `setMode`.
*/
export const useThemeMode = () => useContext(ThemeControlsContext);
+11
View File
@@ -1,3 +1,14 @@
export * from './ThemeContext';
export * from './tokens';
export * from './useTheme';
/**
* Only the categorical TYPES leak out of the palette, never the ramps.
*
* `light` and `dark` are deliberately not re-exported: they are raw colour
* ramps, they are consumed in exactly one place — `buildTheme` in tokens.ts —
* and two bare names that generic, exported from a barrel this widely
* imported, is a collision waiting to happen. A screen that wants a colour
* wants `useTheme()`, which gives it the live one.
*/
export { CATEGORY_HUES, type CategoryHue, type Palette } from './palette';
+371
View File
@@ -0,0 +1,371 @@
/**
* ╔═══════════════════════════════════════════════════════════════════════════╗
* ║ THE COLOUR FILE. THIS IS THE ONE YOU EDIT. ║
* ╚═══════════════════════════════════════════════════════════════════════════╝
*
* Two ramps — one light, one dark — and nothing else. Change a value here and
* every screen in the app that uses the corresponding role follows, because no
* screen names a colour directly: they all read semantic roles off the theme,
* and `tokens.ts` is the single place those roles are mapped onto these ramps.
*
* ── HOW TO CHANGE A COLOUR ──────────────────────────────────────────────────
* 1. Edit the value in `light` and its counterpart in `dark`.
* 2. Run `node scripts/checkContrast.js`. It measures every text-on-surface
* pairing in both ramps and fails if any drops under AA.
* 3. If it fails, the colour is not usable at that role — adjust lightness
* rather than deleting the check.
*
* Both ramps MUST declare exactly the same keys: `buildTheme` in `tokens.ts`
* maps one shape onto the semantic roles, and TypeScript enforces the match
* through the `Palette` type below. A key added to one is a compile error until
* it is added to the other, which is deliberate — a role that only exists in
* light mode is a screen that breaks in dark mode.
*
* ── THE RULES THESE VALUES SATISFY ──────────────────────────────────────────
* Every value was measured with a WCAG 2.1 validator, not chosen by eye, and
* the measured ratio is stated next to it. The constraints:
*
* - Brand navy #124577 is locked in light mode. It stays the identity.
* - Body text and any figure showing money reach AA (4.5:1), validated
* against all three text-bearing surfaces — `surface`, `background` and
* `surfaceMuted` — with a margin over 4.5 so device rounding cannot drop
* a pairing under.
* - Agent screens are used outdoors, so their pairings hold above 7:1.
* - Meaning is never carried by colour alone: every semantic tone ships a
* tint surface and a border, so a state can also be drawn as a shape.
* - A tint surface has to be distinguishable from a plain card, or a banner
* reads as untinted. Measured against the card, not in isolation.
*
* ── THE ONE RULE THAT DIFFERS BETWEEN THE RAMPS ─────────────────────────────
* `textOnFill` is the tone that sits on a saturated fill — a primary button, a
* success pill, a danger chip. In light mode that is WHITE. In dark mode it is
* DARK INK, because a dark theme's tones are lightened to be legible on a dark
* ground, and white on a lightened tone fails badly:
*
* dark mode, white on `brand` 2.61:1 FAILS
* dark mode, ink on `brand` 7.08:1 AA
*
* This is why a button must use `theme.colors.textOnBrand` and never a
* hardcoded '#FFF'. A white label is correct in light mode and unreadable in
* dark mode, and that is not a bug you will see unless you look for it.
*/
/**
* The categorical hues, in the order they should be handed out.
*
* Adjacent entries are deliberately far apart in hue, so a grid that takes
* them in order gets neighbouring tiles that are easy to tell apart — which is
* the entire job of a categorical set.
*/
export const CATEGORY_HUES = [
'blue',
'green',
'violet',
'amber',
'pink',
'sky',
'teal',
'indigo',
] as const;
export type CategoryHue = (typeof CATEGORY_HUES)[number];
export type Palette = {
/* ── surfaces ─────────────────────────────────────────────────────────── */
background: string;
backgroundElevated: string;
/** Vertical wash for a full-bleed header, lightest stop first. */
backgroundGradient: [string, string, string];
surface: string;
surfaceMuted: string;
/** NON-TEXT: progress tracks, skeletons, keypad wells, disabled fills. */
surfaceSunk: string;
border: string;
borderStrong: string;
/* ── text ─────────────────────────────────────────────────────────────── */
textPrimary: string;
textSecondary: string;
textMuted: string;
/** The tone that sits ON a saturated fill. White in light, ink in dark. */
textOnFill: string;
/* ── brand ────────────────────────────────────────────────────────────── */
brand: string;
brandLight: string;
brandDark: string;
brandDeep: string;
brandSurface: string;
brandBorder: string;
/** The full-bleed brand wash — sign-in and registration. */
brandGradient: [string, string, string];
/* ── on the brand wash ────────────────────────────────────────────────── */
onBrandPrimary: string;
onBrandSecondary: string;
onBrandMuted: string;
onBrandSurface: string;
onBrandSurfaceStrong: string;
onBrandBorder: string;
/* ── accent ───────────────────────────────────────────────────────────── */
accent: string;
accentStrong: string;
/** Vivid fill. Takes `textOnAccentFill`, never `textOnFill`. */
accentFill: string;
/**
* The tone that sits on `accentFill`.
*
* Dark ink in BOTH ramps — the vivid brass is light enough that white fails
* on it either way (2.26:1 in light, 1.99:1 in dark). It needs a role of its
* own because the role that happens to BE dark ink differs between the two:
* `textPrimary` in light, `textOnFill` in dark. Without this, a component
* drawing a brass fill would need a `theme.isDark` branch to pick the right
* one, which is the exact thing these roles exist to remove.
*/
textOnAccentFill: string;
accentSurface: string;
accentBorder: string;
/* ── semantic ─────────────────────────────────────────────────────────── */
success: string;
successSurface: string;
successBorder: string;
warning: string;
warningSurface: string;
warningBorder: string;
danger: string;
dangerSurface: string;
dangerBorder: string;
info: string;
infoSurface: string;
infoBorder: string;
/* ── categorical ──────────────────────────────────────────────────────── */
/**
* Decorative accents for telling categories apart — a service tile, a loan
* type, a scheme icon.
*
* NOT semantic. These carry no meaning beyond "different from the one next
* to it", and nothing should read a state off them. If a colour is saying
* "this succeeded" or "this failed", it belongs in the semantic block above;
* mixing the two is how an app ends up with a green that means "paid" in one
* place and "utilities" in another.
*
* Each hue ships a `tone` (the icon or label) and a `surface` (the tint chip
* behind it), because the pattern these replace was `${hue}15` — an 8% alpha
* suffix on a raw hex. That works on a white card and vanishes on a dark
* one, since 8% of anything over near-black is still near-black. A measured
* pair works in both.
*/
category: Record<CategoryHue, { tone: string; surface: string }>;
/* ── misc ─────────────────────────────────────────────────────────────── */
/** Scrim behind a modal or sheet. */
overlay: string;
/** Literal white, for the rare case that wants white regardless of theme. */
white: string;
/** iOS-only shadow colour; Android reads `elevation` and discards this. */
shadow: string;
};
/* ════════════════════════════════════════════════════════════════════════════
* LIGHT
*
* Content on a light ground. Ratios are stated as
* surface / background / surfaceMuted.
* ══════════════════════════════════════════════════════════════════════════ */
export const light: Palette = {
/* Cool neutrals, tinted toward the brand hue (~211°) at 15% saturation so
* the greys read as part of the navy family rather than as flat web grey.
* Steps are spaced by CIE L*, which is the honest metric between two
* near-white fills — a WCAG ratio is not meaningful there. */
background: '#EEF2F7', // L* 95.3 app background
backgroundElevated: '#FFFFFF',
backgroundGradient: ['#FFFFFF', '#F7F9FC', '#EEF2F7'],
surface: '#FFFFFF', // L* 100.0 cards
surfaceMuted: '#E2EAF2', // L* 92.3 pressed rows, wells
surfaceSunk: '#D5E0EA', // L* 88.6 non-text only
border: '#D3DDE7', // 1.32:1 on a card, so it is actually visible
borderStrong: '#B4C3D2',
textPrimary: '#0D1B2A', // 17.39 / 15.47 / 14.32
textSecondary: '#48596B', // 7.20 / 6.40 / 5.93 — a neutral slate, NOT
// the brand navy, which is what it used to be
// and why the whole app read as one flat colour
textMuted: '#5A697A', // 5.62 / 5.00 / 4.63
textOnFill: '#FFFFFF', // white on brand navy is 7.12:1
brand: '#124577', // ◀ LOCKED IDENTITY
brandLight: '#1B5A96',
brandDark: '#0D3459',
brandDeep: '#092440',
brandSurface: '#E9F0F7',
brandBorder: '#B9CDE0',
// Stops at brandDark rather than brandDeep: the deepest navy reads as
// near-black at the foot of a phone screen, and the wash should still be
// recognisably the brand colour where content sits on it.
brandGradient: ['#1B5A96', '#124577', '#0D3459'],
/* On the navy wash. Each tier is validated against the LIGHTEST stop
* (#1B5A96), which is the worst case.
*
* THE TIERS DO NOT STACK. A translucent fill lightens the wash beneath it,
* so a translucent tone on a translucent fill has less to work with:
*
* white @0.85 on the bare wash 5.64:1 AA
* white @0.85 on an @0.12 fill 4.37:1 FAILS
* white (solid) on an @0.12 fill 5.36:1 AA
*
* Text directly on the wash may use any tier. Text on a FILL takes the
* solid tone, always. */
onBrandPrimary: '#FFFFFF', // 7.12:1 on the lightest stop
onBrandSecondary: 'rgba(255, 255, 255, 0.85)', // 5.64:1 — body-safe
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 4.80:1 — still AA
onBrandSurface: 'rgba(255, 255, 255, 0.12)', // white on it 5.36:1
onBrandSurfaceStrong: 'rgba(255, 255, 255, 0.18)', // white on it 4.66:1 —
// this is the ceiling
onBrandBorder: 'rgba(255, 255, 255, 0.24)',
/* Brass. Navy and gold is the pairing a bank has used for a century, and it
* deliberately avoids the acid-green / violet-on-near-black look. Accent and
* success used to be the same #10B981, so a promotional highlight and a
* success state were indistinguishable. */
accent: '#876013', // 5.66 / 5.03 / 4.66 — and white-on-it 5.66:1
accentStrong: '#6B4C0F',
accentFill: '#DDA22C', // ink on it 7.69:1; WHITE ON IT IS 2.26:1
textOnAccentFill: '#0D1B2A', // 7.69:1 — the same ink as `textPrimary` here
accentSurface: '#FEF7EA',
accentBorder: '#EBCB8A',
/* Each tone is the lightest — so most vivid — value that still clears 4.6:1
* on all three text-bearing surfaces. */
success: '#0C7645', // 5.69 / 5.06 / 4.68
successSurface: '#E6F5ED',
successBorder: '#9FD8BC',
warning: '#955A00', // 5.61 / 4.99 / 4.62
warningSurface: '#FDF3E3',
warningBorder: '#EFCB8C',
danger: '#C52A1F', // 5.64 / 5.01 / 4.64
dangerSurface: '#FCEBE9',
dangerBorder: '#EFB4AE',
info: '#0F6CAA', // 5.61 / 4.99 / 4.61
infoSurface: '#E8F1F8',
infoBorder: '#A7C9E2',
/* Ratios below are tone-on-card / tone-on-its-own-tint / tint-vs-card. */
category: {
blue: { tone: '#1D4ED8', surface: '#E8EDFD' }, // 6.71 / 6.03 / 1.11
green: { tone: '#0E7A4A', surface: '#E6F5EE' }, // 5.35 / 4.82 / 1.11
violet: { tone: '#7A35D1', surface: '#F1E9FC' }, // 5.99 / 5.19 / 1.15
amber: { tone: '#8A5A08', surface: '#FBF2E2' }, // 5.87 / 5.03 / 1.17
pink: { tone: '#BE185D', surface: '#FCE8F0' }, // 6.20 / 5.53 / 1.12
sky: { tone: '#0369A1', surface: '#E4F1F9' }, // 5.62 / 5.02 / 1.12
teal: { tone: '#036D66', surface: '#E0F3F1' }, // 5.63 / 5.00 / 1.13
indigo: { tone: '#4F46E5', surface: '#ECEAFE' }, // 5.85 / 5.07 / 1.13
},
overlay: 'rgba(9, 36, 64, 0.55)',
white: '#FFFFFF',
shadow: '#092440',
};
/* ════════════════════════════════════════════════════════════════════════════
* DARK
*
* Not the light ramp inverted. Three things genuinely change:
*
* 1. Every tone LIGHTENS. #124577 on a dark ground is 1.6:1 — the brand
* navy is invisible in dark mode, so the interactive brand becomes a
* lighter navy and the identity is carried by hue, not by that exact hex.
* 2. `textOnFill` becomes DARK INK. See the note at the top of this file.
* 3. Depth stops coming from shadow. A shadow on a near-black ground is
* invisible, so separation is carried by the surface steps and the
* borders below — which is why each step is measured against its
* neighbour and not in isolation.
*
* The ground is navy-tinted rather than neutral black: it keeps the app in the
* brand family, and pure #000 under light text is the classic recipe for
* halation on an OLED panel.
* ══════════════════════════════════════════════════════════════════════════ */
export const dark: Palette = {
background: '#0B1420',
backgroundElevated: '#131F2C',
backgroundGradient: ['#131F2C', '#0F1A26', '#0B1420'],
surface: '#131F2C',
surfaceMuted: '#1B2937',
surfaceSunk: '#243343', // non-text; only textPrimary 11.31, textSecondary
// 7.39 and brand 4.93 are legible on it
border: '#2E4053', // 1.57:1 against a card
borderStrong: '#3F556D', // 2.17:1
textPrimary: '#EAF1F8', // 14.63 / 16.24 / 12.99
textSecondary: '#B6C6D6', // 9.55 / 10.60 / 8.48
textMuted: '#93A6B9', // 6.66 / 7.40 / 5.92
textOnFill: '#0B1420', // ink on `brand` 7.08:1 — white would be 2.61:1
brand: '#6BA5DC', // 6.38 / 7.08 / 5.67
brandLight: '#8CBEE9',
brandDark: '#4A80B5',
brandDeep: '#0A1E33',
brandSurface: '#17304A', // brand on it 5.16:1; separation from a card 1.24
brandBorder: '#2D4B6B', // 1.85:1 against a card
// The wash DARKENS in dark mode rather than lightening. White on the
// lightest stop is 10.89:1, so every on-brand tier below clears AA with
// room to spare — they were calibrated against the far lighter light-mode
// wash, which is the harder case.
brandGradient: ['#173F63', '#102D49', '#0A1E33'],
onBrandPrimary: '#FFFFFF', // 10.89:1 on the lightest stop
onBrandSecondary: 'rgba(255, 255, 255, 0.85)', // 8.35:1
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 6.72:1
onBrandSurface: 'rgba(255, 255, 255, 0.12)', // white on it 7.68:1
onBrandSurfaceStrong: 'rgba(255, 255, 255, 0.18)',
onBrandBorder: 'rgba(255, 255, 255, 0.24)',
accent: '#E8BC63', // 9.37 / 10.40 / 8.32
accentStrong: '#F0CE8A',
accentFill: '#DDA22C', // the same fill as light mode, and the same rule:
// it takes ink, never white
textOnAccentFill: '#0B1420', // 8.17:1. Note this is NOT `textPrimary`, which
// is near-white here and only 1.99:1 on it —
// the reason this role exists at all
accentSurface: '#332A18', // accent on it 7.95:1; separation 1.18
accentBorder: '#5A4520',
success: '#4FC98D', // 7.99 / 8.87 / 7.10
successSurface: '#143223', // success on it 6.67:1; separation 1.20
successBorder: '#245C40',
warning: '#E3AC4E', // 8.16 / 9.05 / 7.24
warningSurface: '#322717', // warning on it 7.15:1; separation 1.14
warningBorder: '#57431D',
danger: '#F2827A', // 6.55 / 7.27 / 5.81
dangerSurface: '#472420', // danger on it 5.35:1; separation 1.22
dangerBorder: '#5E2E2A',
info: '#63B0E3', // 7.02 / 7.80 / 6.24
infoSurface: '#152C3A', // info on it 6.09:1; separation 1.15
infoBorder: '#265066',
/* Same hues, lightened onto the dark card, each with a deepened tint. The
* hue ORDER matches the light ramp — a category keeps its colour identity
* across themes, so a tile a customer knows as the violet one stays violet.
* Ratios: tone-on-card / tone-on-its-own-tint / tint-vs-card. */
category: {
blue: { tone: '#7FA8F5', surface: '#1A2544' }, // 7.13 / 5.34 / 1.14
green: { tone: '#4FC98D', surface: '#143223' }, // 7.99 / 6.67 / 1.20
violet: { tone: '#C08CF0', surface: '#2A1D3D' }, // 7.28 / 5.86 / 1.11
amber: { tone: '#E3AC4E', surface: '#322717' }, // 8.16 / 7.15 / 1.14
pink: { tone: '#F58AB4', surface: '#3A1D2C' }, // 7.28 / 6.59 / 1.11
sky: { tone: '#63B0E3', surface: '#152C3A' }, // 7.02 / 6.09 / 1.15
teal: { tone: '#3FC5BC', surface: '#0F2E2C' }, // 7.60 / 6.63 / 1.13
indigo: { tone: '#A5A0FB', surface: '#221F42' }, // 7.35 / 5.72 / 1.12
},
// Heavier than the light scrim: the sheet beneath it is dark too, so the
// scrim has less tonal distance to work with.
overlay: 'rgba(4, 9, 15, 0.72)',
white: '#FFFFFF',
shadow: '#000000',
};
+641 -94
View File
@@ -1,42 +1,558 @@
import type { ViewStyle } from 'react-native';
import { dark, light, type CategoryHue, type Palette } from './palette';
/**
* BankCBS design tokens.
*
* ── WHERE THINGS LIVE ───────────────────────────────────────────────────────
* palette.ts the two colour ramps. THAT is the file you edit to change a
* colour; it is the only place a hex value is written.
* tokens.ts this file. Maps a ramp onto the semantic roles screens
* actually use, and holds everything that does NOT change
* between light and dark: the type scale, radius, spacing and
* layout constants.
*
* The mapping is written once, in `buildTheme`, and applied to both ramps. So
* a role's meaning cannot drift between the two themes — if `primarySurface`
* means "the tint behind a selected row", it means that in both, and adding a
* role to one theme without the other is a compile error.
*
* ── HOW A SCREEN USES THIS ──────────────────────────────────────────────────
* const theme = useTheme();
* <Text style={[theme.text.h2, { color: theme.colors.textPrimary }]}>
*
* Colours must be read through `useTheme()`, because they change at runtime
* when the theme does. The TYPE SCALE does not change between themes, so it
* can also be imported directly for use inside `StyleSheet.create`, where
* there is no hook:
*
* import { textStyles } from '../../theme';
* const styles = StyleSheet.create({ title: { ...textStyles.h3 } });
*
* That is the supported way to set type in a static stylesheet. Writing a raw
* `fontSize` / `fontWeight` pair instead is what this file exists to replace —
* see the typography note below for why `fontWeight` in particular is a bug
* on Android rather than a style choice.
*
* React Native constraints this file respects: one outer shadow per element,
* with Android ignoring its colour and offset entirely — so depth is carried
* by surface-tone steps and real borders, and the shadow is only a lift on
* iOS. No blur, no inner or stacked shadows, no coloured glows.
*/
/* ────────────────────────────────────────────────────────────────────────────
* TYPOGRAPHY — identical in both themes.
*
* Two families, each with a job:
*
* IBM PLEX SANS — everything that is language. A grotesque drawn for
* technical and institutional work, which is the register a
* bank wants. Static weights: 400, 500, 600, 700.
* IBM PLEX MONO — everything that is a figure or a fixed-format code:
* balances, amounts in a column, account numbers, IFSC
* codes, reference numbers. Weights: 400, 600.
*
* The split is functional, not decorative. Money has to align on the decimal
* down a column, and a proportional face cannot do that without an OpenType
* feature Android applies inconsistently. Monospace gets it for nothing.
*
* ── WHAT WAS HERE BEFORE, AND WHY IT IS GONE ────────────────────────────────
* This file used to declare Nunito. The four Nunito .ttf files in the repo
* were not fonts: `download-fonts.js` had been run after its source URLs died
* and, with no status check, wrote GitHub's 404 page into each one — four
* 300KB HTML documents with a .ttf extension. React Native does not validate
* a font asset, so Android fell back to Roboto: the app shipped in the system
* font while every style in here named a typeface it had never loaded. The
* download script now verifies the TrueType magic number before keeping a file.
*
* ── SELECTING A WEIGHT ──────────────────────────────────────────────────────
* Weight is selected by FONT FAMILY, never by `fontWeight`. Setting both is
* the documented way to get the wrong face on Android, and setting only
* `fontWeight` gets you the SYSTEM font at that weight — not Plex at all —
* which is why every style below carries `fontFamily` and deliberately omits
* `fontWeight`.
*
* The filenames are the fonts' own PostScript names — `IBMPlexSans-SmBld`,
* not `IBMPlexSans-SemiBold` — because iOS resolves a family against the
* PostScript name and Android against the asset filename, and the two only
* agree if the file is named after the former. Do not tidy those names.
*
* Plex is already an institutional face, so the formality no longer has to be
* manufactured by the type scale. What the scale still does deliberately:
* - mono for every figure, so columns of money line up;
* - Medium reserved for emphasis and SemiBold for controls, so "important"
* and "tappable" stop looking like the same thing;
* - uppercase with wide tracking on labels and eyebrows.
* ──────────────────────────────────────────────────────────────────────────── */
export const fontFamily = {
/* ── IBM Plex Sans: everything that is language ─────────────────────────── */
regular: 'IBMPlexSans', // 400
medium: 'IBMPlexSans-Medm', // 500
semibold: 'IBMPlexSans-SmBld', // 600
bold: 'IBMPlexSans-Bold', // 700 — the heaviest weight Plex ships
/* ── IBM Plex Mono: everything that is a figure or a code ───────────────── */
mono: 'IBMPlexMono', // 400
monoSemibold: 'IBMPlexMono-SmBld', // 600
/**
* @deprecated Plex has no ExtraBold — Bold is its heaviest weight. Mapped
* here so existing call sites keep rendering a real face instead of falling
* back to the system font. Use `bold`.
*/
extrabold: 'IBMPlexSans-Bold',
} as const;
/**
* Ready-to-spread text styles. Spread one, then set only `color`:
*
* <Text style={[theme.text.h2, { color: theme.colors.textPrimary }]}>
*
* or, inside a static stylesheet where there is no hook:
*
* const styles = StyleSheet.create({ title: { ...textStyles.h2 } });
*
* Note on money alignment: `balance`, `money`, `moneyLarge` and `code` are set
* in Plex Mono, so their digits are already one width and no `fontVariant`
* feature flag is involved. Right-align a column of amounts all the same — the
* font holds the digits on a grid, and the alignment is what puts the decimal
* points above one another.
*/
const text = {
/**
* The hero balance figure. One per screen, at most.
*
* Set in MONO. A balance is a figure, not a sentence: monospace digits are
* all one width, so the number does not reflow as it changes and the decimal
* holds its position. Slightly smaller than the sans equivalent would be,
* because mono runs wider per character.
*/
balance: {
fontFamily: fontFamily.monoSemibold,
fontSize: 36,
lineHeight: 44,
letterSpacing: -1,
},
/**
* The largest text on a screen — an onboarding headline, a screen title that
* has to carry the whole page. Sans, not mono: this is language. A figure
* that needs this much presence uses `balance`.
*/
display: {
fontFamily: fontFamily.bold,
fontSize: 32,
lineHeight: 39,
letterSpacing: -0.7,
},
h1: {
fontFamily: fontFamily.bold,
fontSize: 26,
lineHeight: 32,
letterSpacing: -0.4,
},
h2: {
fontFamily: fontFamily.bold,
fontSize: 21,
lineHeight: 27,
letterSpacing: -0.3,
},
h3: {
fontFamily: fontFamily.bold,
fontSize: 17,
lineHeight: 23,
letterSpacing: -0.1,
},
/**
* Body, emphasised — a row title, a name.
*
* Medium, a weight Nunito never had. It leaves SemiBold free to mean
* "this is a control" on buttons and labels, so emphasis and interactivity
* stop looking like the same thing.
*/
bodyStrong: {
fontFamily: fontFamily.medium,
fontSize: 15,
lineHeight: 22,
letterSpacing: 0,
},
body: {
fontFamily: fontFamily.regular,
fontSize: 15,
lineHeight: 22,
letterSpacing: 0,
},
bodySmall: {
fontFamily: fontFamily.regular,
fontSize: 13,
lineHeight: 19,
letterSpacing: 0,
},
caption: {
fontFamily: fontFamily.regular,
fontSize: 12,
lineHeight: 16,
letterSpacing: 0.1,
},
/**
* Uppercase eyebrow / field label. The wide tracking is where the
* institutional formality comes from — do not reduce it. SemiBold rather
* than Bold: Plex Bold set in caps at 11pt reads as a shout.
*/
eyebrow: {
fontFamily: fontFamily.semibold,
fontSize: 11,
lineHeight: 14,
letterSpacing: 0.9,
textTransform: 'uppercase' as const,
},
button: {
fontFamily: fontFamily.semibold,
fontSize: 15,
lineHeight: 20,
letterSpacing: 0.2,
},
/**
* Money in a list or table. Right-align it.
*
* Mono, which is what finally makes a column of amounts line up. The old
* sans version asked for the same thing with `fontVariant:
* ['tabular-nums']` — an OpenType feature iOS honours and Android applies
* inconsistently, so the decimals drifted on most of the user base.
* Monospace needs no feature flag: every digit is the same width by
* construction.
*/
money: {
fontFamily: fontFamily.monoSemibold,
fontSize: 15,
lineHeight: 20,
letterSpacing: -0.2,
},
/** Money that carries the screen — a detail header, a confirmation. */
moneyLarge: {
fontFamily: fontFamily.monoSemibold,
fontSize: 19,
lineHeight: 25,
letterSpacing: -0.3,
},
/**
* Fixed-format identifiers: IFSC, account number, reference number, a masked
* mobile. Mono, so the characters sit on a grid and a customer can read one
* back over the phone without losing their place.
*
* Tracking is 0.4 rather than the 1.2 this carried as a proportional face —
* that width was propping up a font that did not have it, and mono already
* does.
*/
code: {
fontFamily: fontFamily.mono,
fontSize: 14,
lineHeight: 20,
letterSpacing: 0.4,
},
} as const;
/**
* The type scale, importable without a hook.
*
* Type does not change between light and dark — only colour does — so a static
* `StyleSheet.create` can spread these directly and still be part of the
* design system. Set the colour separately, from `useTheme()`.
*/
export const textStyles = text;
/* ────────────────────────────────────────────────────────────────────────────
* RADIUS
*
* Radius is assigned by role, not by size. One uniform corner stamped on every
* element flattens hierarchy — a full-bleed hero and a 20pt status pill should
* not share a corner.
* ──────────────────────────────────────────────────────────────────────────── */
const radius = {
sm: 6, // chips, badges, small inline marks
md: 10, // buttons, inputs, list rows
lg: 14, // cards
xl: 18, // the hero balance card, promo surfaces
xxl: 24, // bottom sheet top corners
pill: 999, // segmented control thumb, filter chips, status pills
} as const;
/* ────────────────────────────────────────────────────────────────────────────
* SPACING — a 4pt grid.
* ──────────────────────────────────────────────────────────────────────────── */
const spacing = {
xxs: 4,
xs: 8,
sm: 12,
md: 16, // the standard screen gutter
lg: 20,
xl: 24,
xxl: 32,
xxxl: 48,
} as const;
/* ────────────────────────────────────────────────────────────────────────────
* LAYOUT — platform facts screens have to design around.
* ──────────────────────────────────────────────────────────────────────────── */
const layout = {
/** The bottom tab bar floats above content at this height. */
tabBarHeight: 90,
/** Bottom padding a scrolling page must reserve so its last row is not
* trapped behind the floating tab bar. */
scrollBottomInset: 106,
/** Minimum touch target. Agent screens use the larger one — that person is
* working one-handed, at speed, standing up. */
touchTarget: 44,
touchTargetAgent: 48,
/** Standard control heights. */
controlHeight: 52,
controlHeightAgent: 56,
controlHeightSmall: 40,
/** The screen gutter. */
gutter: 16,
hairline: 1,
} as const;
/* ────────────────────────────────────────────────────────────────────────────
* ELEVATION
*
* React Native allows exactly one outer shadow per element, and Android
* ignores its colour and offset — it only reads `elevation`. A design that
* leans on a soft coloured shadow therefore looks flat for most of the user
* base, so each level pairs its shadow with a `borderColor`, and the border is
* what actually separates the surface on Android.
*
* Built per theme, because both halves of that are theme-dependent: the border
* comes from the ramp, and a shadow does almost nothing on a dark ground. In
* dark mode the shadow is kept (it still reads faintly on iOS against the
* lighter card tone) but separation is really carried by the surface steps and
* the border — which is why the dark ramp measures each step against its
* neighbour.
*
* Spread these onto a view that already has a backgroundColor and borderWidth.
* ──────────────────────────────────────────────────────────────────────────── */
const buildElevation = (p: Palette) =>
({
/** Flat. A border does all the work — grouped rows, inline banners. */
level0: {
borderColor: p.border,
shadowColor: p.shadow,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
/** Resting card. The default for content sitting on the app background. */
level1: {
borderColor: p.border,
shadowColor: p.shadow,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 3,
elevation: 1,
},
/** Raised — a pinned footer, a sticky header, the hero balance card. */
level2: {
borderColor: p.border,
shadowColor: p.shadow,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 10,
elevation: 4,
},
/** Overlay — bottom sheets and modals, which sit above a scrim. */
level3: {
borderColor: p.borderStrong,
shadowColor: p.shadow,
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.16,
shadowRadius: 22,
elevation: 12,
},
}) as const;
type Elevation = ReturnType<typeof buildElevation>;
/* ────────────────────────────────────────────────────────────────────────────
* THEME
* ──────────────────────────────────────────────────────────────────────────── */
/** Which ramp a theme was built from. */
export type ThemeName = 'light' | 'dark';
export interface FintechTheme {
/** Which ramp this is. For the rare case that genuinely has to branch —
* picking one of two image assets, say. Never for colour: that is what the
* roles below are for. */
name: ThemeName;
/** Convenience for the same thing. */
isDark: boolean;
/**
* Status-bar icon style for a screen sitting on `colors.background`.
*
* Derived here so the light/dark ternary is written once. A screen on the
* full-bleed `brandGradient` does NOT use this — that wash is dark in both
* themes, so those screens hardcode `light-content` and are correct to.
*/
statusBarStyle: 'light-content' | 'dark-content';
colors: {
/* ── surfaces ─────────────────────────────────────────────────────────── */
/** App background. */
background: string;
backgroundElevated: string;
/** Vertical wash for a full-bleed header. */
backgroundGradient: [string, string, string];
/** Cards. Primary text on this is 14.63:1 or better in both themes. */
surface: string;
/** Muted surface — pressed rows, wells, grouped backgrounds. */
surfaceMuted: string;
/**
* Sunk surface. NON-TEXT: progress tracks, skeleton bases, keypad wells,
* disabled fills. Only `textPrimary`, `textSecondary` and `primary` are
* legible on it — muted text, success and the accent all fall below AA
* here, so do not put them on this surface.
*/
surfaceSunk: string;
/** @deprecated There is no blur library in this app, so nothing is glass.
* Kept only so existing callers still compile; it resolves to `surface`.
* Use `surface`. */
cardGlass: string;
/** Default border. Visible against a card in both themes — the original
* rgba(0,0,0,0.05) was not, which is why cards had no edges. */
cardBorder: string;
/** Stronger border — a focused field, a selected card, a divider that has
* to carry weight on its own. */
cardBorderStrong: string;
/* ── text ─────────────────────────────────────────────────────────────── */
textPrimary: string;
textSecondary: string;
/** Placeholders, timestamps, helper text. */
textMuted: string;
/**
* For text and icons sitting on a saturated fill — a navy, danger or
* success button.
*
* NOT always white. It is white in light mode and dark ink in dark mode,
* because dark mode lightens its tones and white on a lightened tone is
* about 2.6:1. This is the role that makes a hardcoded '#FFF' on a button
* a real bug rather than a shortcut.
*/
textOnBrand: string;
/* ── brand ────────────────────────────────────────────────────────────── */
/** Primary actions, active states, brand moments. #124577 in light mode;
* a lighter navy in dark, where #124577 would be invisible. */
primary: string;
/** Lighter brand — the second stop of a gradient. Not a sky blue. */
primaryLight: string;
/** Pressed state for a brand fill. */
primaryDark: string;
/** Deepest brand tone — a full-bleed header. */
primaryDeep: string;
/** Brand tint — a selected row, a brand banner, an active chip. */
primarySurface: string;
primaryBorder: string;
/**
* The brand wash for a full-bleed screen, lightest stop first. Sign-in and
* registration use it; everywhere else is content on the app background.
* Requires LIGHT status-bar icons in both themes.
*/
brandGradient: [string, string, string];
/* ── on-brand: text and fills for use ON the wash above ───────────────── */
/** Solid white. The only tier legal on top of a translucent fill. */
onBrandPrimary: string;
/** Body-safe on the bare wash. */
onBrandSecondary: string;
/** The quietest line, still AA on the bare wash. */
onBrandMuted: string;
/** Low-opacity white fill for a tile. */
onBrandSurface: string;
/** A heavier fill, for a secondary button. This is the ceiling — see the
* note in palette.ts. */
onBrandSurfaceStrong: string;
onBrandBorder: string;
/* ── accent (brass) ───────────────────────────────────────────────────── */
/** Text-safe brass. Also safe as a fill with `textOnBrand` on it. */
accent: string;
/** Pressed accent. */
accentStrong: string;
/** Vivid brass fill. Pair it with `textOnAccentFill` — never
* `textOnBrand`, which is 2.26:1 on it in light mode. */
accentFill: string;
/** The tone that sits on `accentFill`: dark ink in both themes. Use this
* rather than `textPrimary`, which is near-white in dark mode and 1.99:1
* on the brass. */
textOnAccentFill: string;
accentSurface: string;
accentBorder: string;
/* ── semantic ─────────────────────────────────────────────────────────── */
/** A credit, a completed transfer, an active account. Always paired with
* an icon or a sign — never colour alone. */
success: string;
successSurface: string;
successBorder: string;
warning: string;
warningSurface: string;
warningBorder: string;
danger: string;
dangerSurface: string;
dangerBorder: string;
info: string;
overlay: string;
white: string;
infoSurface: string;
infoBorder: string;
/** Alias of `danger`, kept because ~30 call sites use it for field errors. */
error: string;
// Cyber Nexus Accents
/* ── categorical ──────────────────────────────────────────────────────── */
/**
* Decorative accents for telling categories apart — a service tile, a loan
* type, a scheme icon. Each hue carries a `tone` for the icon or label and
* a `surface` for the tint chip behind it.
*
* These are NOT states. Do not read success or failure off them; the
* semantic roles above are for that.
*/
category: Record<CategoryHue, { tone: string; surface: string }>;
/* ── misc ─────────────────────────────────────────────────────────────── */
/** Scrim behind a modal or sheet. */
overlay: string;
/**
* iOS-only shadow colour; Android reads `elevation` and discards it.
*
* Prefer `theme.elevation.level1` and friends, which carry this plus the
* border Android actually needs. This is here for a one-off lift that does
* not want a whole elevation level.
*/
shadow: string;
/** Literal white, regardless of theme. Almost always the wrong choice —
* you probably want `surface` or `textOnBrand`. */
white: string;
/** @deprecated Left over from an earlier "cyber" theme that this app is
* not. All three resolve into the brand family so existing call sites
* render correctly; use `primary` / `primarySurface` instead. */
cyberCyan: string;
/** @deprecated Use `primary`. */
cyberPurple: string;
/** @deprecated Use `primarySurface`. */
cyberGlow: string;
};
spacing: {
xxs: number;
xs: number;
sm: number;
md: number;
lg: number;
xl: number;
xxl: number;
xxxl: number;
};
spacing: typeof spacing;
/** Raw font sizes, kept for existing callers. Prefer `theme.text`, which
* carries the family, line height and tracking as well. */
typography: {
hero: number;
h1: number;
@@ -46,92 +562,123 @@ export interface FintechTheme {
bodySmall: number;
caption: number;
};
radius: {
sm: number;
md: number;
lg: number;
xl: number;
xxl: number;
pill: number;
};
/** The type scale. Spread a style, then set `color`. */
text: typeof text;
fontFamily: typeof fontFamily;
radius: typeof radius;
layout: typeof layout;
elevation: Elevation;
shadows: {
soft: {
shadowColor: string;
shadowOffset: { width: number; height: number };
shadowOpacity: number;
shadowRadius: number;
elevation: number;
};
/** @deprecated Use `theme.elevation.level1`, which also carries the border
* that Android needs in order to show any separation at all. */
soft: ViewStyle;
};
}
// Keeping the export name as `darkTheme` for now so we don't break every import,
// but the theme values represent the new Light Corporate Design language.
export const darkTheme: FintechTheme = {
colors: {
background: '#F1F5F9', // Light gray background
backgroundElevated: '#FFFFFF',
backgroundGradient: ['#FFFFFF', '#F8FAFC', '#F1F5F9'],
surface: '#FFFFFF',
surfaceMuted: '#F8FAFC',
cardGlass: 'rgba(255, 255, 255, 0.9)',
cardBorder: 'rgba(0, 0, 0, 0.05)',
textPrimary: '#0F172A',
textSecondary: '#124577',
textMuted: '#94A3B8',
primary: '#124577', // Brand Light Blue/Cyan
primaryLight: '#60A5FA',
primaryDark: '#2563EB',
accent: '#10B981', // Brand Green
accentStrong: '#059669',
success: '#10B981',
warning: '#F59E0B',
danger: '#EF4444',
info: '#3B82F6',
overlay: 'rgba(0, 0, 0, 0.4)',
white: '#FFFFFF',
error: '#EF4444',
// Cyber Nexus Accents (Resetting to neutral or removing usage)
cyberCyan: '#3B82F6',
cyberPurple: '#6366F1',
cyberGlow: 'rgba(59, 130, 246, 0.1)',
},
spacing: {
xxs: 4,
xs: 8,
sm: 12,
md: 16,
lg: 20,
xl: 24,
xxl: 32,
xxxl: 48,
},
typography: {
hero: 34,
h1: 28,
h2: 22,
h3: 18,
body: 16,
bodySmall: 14,
caption: 12,
},
radius: {
sm: 6,
md: 8,
lg: 12,
xl: 16,
xxl: 20,
pill: 999,
},
shadows: {
soft: {
shadowColor: '#000000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 3,
/**
* Maps one colour ramp onto the semantic roles above.
*
* Written once and applied to both ramps, so a role cannot come to mean
* different things in light and dark. If you are adding a role, add it to
* `Palette` in palette.ts first — both ramps then have to supply it, which is
* the check that stops a screen from being correct in one theme only.
*/
const buildTheme = (p: Palette, name: ThemeName): FintechTheme => {
const elevation = buildElevation(p);
return {
name,
isDark: name === 'dark',
statusBarStyle: name === 'dark' ? 'light-content' : 'dark-content',
colors: {
background: p.background,
backgroundElevated: p.backgroundElevated,
backgroundGradient: p.backgroundGradient,
surface: p.surface,
surfaceMuted: p.surfaceMuted,
surfaceSunk: p.surfaceSunk,
cardGlass: p.surface,
cardBorder: p.border,
cardBorderStrong: p.borderStrong,
textPrimary: p.textPrimary,
textSecondary: p.textSecondary,
textMuted: p.textMuted,
textOnBrand: p.textOnFill,
primary: p.brand,
primaryLight: p.brandLight,
primaryDark: p.brandDark,
primaryDeep: p.brandDeep,
primarySurface: p.brandSurface,
primaryBorder: p.brandBorder,
brandGradient: p.brandGradient,
onBrandPrimary: p.onBrandPrimary,
onBrandSecondary: p.onBrandSecondary,
onBrandMuted: p.onBrandMuted,
onBrandSurface: p.onBrandSurface,
onBrandSurfaceStrong: p.onBrandSurfaceStrong,
onBrandBorder: p.onBrandBorder,
accent: p.accent,
accentStrong: p.accentStrong,
accentFill: p.accentFill,
textOnAccentFill: p.textOnAccentFill,
accentSurface: p.accentSurface,
accentBorder: p.accentBorder,
success: p.success,
successSurface: p.successSurface,
successBorder: p.successBorder,
warning: p.warning,
warningSurface: p.warningSurface,
warningBorder: p.warningBorder,
danger: p.danger,
dangerSurface: p.dangerSurface,
dangerBorder: p.dangerBorder,
info: p.info,
infoSurface: p.infoSurface,
infoBorder: p.infoBorder,
error: p.danger,
category: p.category,
overlay: p.overlay,
shadow: p.shadow,
white: p.white,
cyberCyan: p.brand,
cyberPurple: p.brandLight,
cyberGlow: p.brandSurface,
},
},
spacing,
typography: {
hero: text.balance.fontSize,
h1: text.h1.fontSize,
h2: text.h2.fontSize,
h3: text.h3.fontSize,
body: text.body.fontSize,
bodySmall: text.bodySmall.fontSize,
caption: text.caption.fontSize,
},
text,
fontFamily,
radius,
layout,
elevation,
shadows: {
soft: elevation.level1,
},
};
};
export const lightTheme: FintechTheme = buildTheme(light, 'light');
export const darkTheme: FintechTheme = buildTheme(dark, 'dark');
/** Both themes by name, for the provider to select from. */
export const themes: Record<ThemeName, FintechTheme> = {
light: lightTheme,
dark: darkTheme,
};