Update CBS Mobile App codebase
This commit is contained in:
+136
-6
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user