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(); * * * 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`: * * * * 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; /* ──────────────────────────────────────────────────────────────────────────── * 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; infoSurface: string; infoBorder: string; /** Alias of `danger`, kept because ~30 call sites use it for field errors. */ error: string; /* ── 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; /* ── 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: 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; h2: number; h3: number; body: number; bodySmall: number; caption: 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: { /** @deprecated Use `theme.elevation.level1`, which also carries the border * that Android needs in order to show any separation at all. */ soft: ViewStyle; }; } /** * 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 = { light: lightTheme, dark: darkTheme, };