Files
CBS-Mobile-App/src/theme/tokens.ts
T

685 lines
28 KiB
TypeScript

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;
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<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: 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<ThemeName, FintechTheme> = {
light: lightTheme,
dark: darkTheme,
};