Files
CBS-Mobile-App/src/theme/palette.ts
T
Abin ToppoandClaude Opus 5.5 f960a119e8 Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on
- Positive Pay on the 12 Oct /pps contract (account number, not account ID):
  OTP enrollment, cheque submit, saved cheques, detail and stop payment.
  Full flow passed on the phone against mobile-banking-backend.lincpay.
- Statement & Mini Statement guide v1.0 (14 Oct): mini statement JSON on
  Home and the Statement screen; dated PDF export with the password rule.
- Onboarding slides and splash screen, Inter font, shared Icon and
  TypewriterText components, nearby branches via OpenStreetMap.
- Every feature switched on; the 14 Oct switch set is kept in
  featureConfig.stage-2026-10-14.ts. Loans run on demo data until the
  backend has /loans routes.
- npm run android wraps run-android for shells spawned from VS Code.
- OpenProject work packages updated to 14-10-2026 status, with screenshots
  of Positive Pay, statement, loans and cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:18:40 +05:30

384 lines
19 KiB
TypeScript

/**
* ╔═══════════════════════════════════════════════════════════════════════════╗
* ║ 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 DESIGN LANGUAGE ─────────────────────────────────────────────────────
* Royal blue on white, with deep navy for the full-bleed moments. Clean,
* airy, institutional: a white card on a cool blue-grey ground, one vivid blue
* for everything tappable, and navy reserved for the sign-in wash and the
* header band on Home. The neutrals are tinted a few degrees toward the brand
* hue, so the greys read as part of the blue family rather than as flat grey.
*
* ── 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 blue #0B57D0 is the identity in light mode.
* - 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.39:1 FAILS
* dark mode, ink on `brand` 7.75: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 navy wash — sign-in, registration, the Home header band. */
brandGradient: [string, string, string];
/**
* The vivid blue gradient for a hero object — the account card, a promo
* banner. Brighter than `brandGradient`, which is a backdrop; this is a
* thing sitting on the page. Carries the same on-brand tiers, and the
* contrast script validates them against its lightest stop too.
*/
heroGradient: [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 gold is light enough that white fails
* on it either way. 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 gold 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 blue. A white card on a pale
* blue-grey ground is the whole look: the step between them is small, so
* cards are separated by a real border and a soft lift, not by a heavy
* tonal jump. */
background: '#F3F6FB', // app background — cool, barely-blue
backgroundElevated: '#FFFFFF',
backgroundGradient: ['#FFFFFF', '#F8FAFD', '#F3F6FB'],
surface: '#FFFFFF', // cards
surfaceMuted: '#EDF1F8', // pressed rows, wells, input fills
surfaceSunk: '#E2E8F2', // non-text only
border: '#DCE3EE', // 1.29:1 on a card, so it is actually visible
borderStrong: '#C7D2E1', // 1.53:1
textPrimary: '#0B1B3F', // 16.90 / 15.60 / 14.92 — a navy ink, not black
textSecondary: '#4A5872', // 7.17 / 6.62 / 6.33
textMuted: '#5C6A82', // 5.47 / 5.05 / 4.83
textOnFill: '#FFFFFF', // white on brand blue is 6.39:1
brand: '#0B57D0', // ◀ IDENTITY — 6.39 / 5.90 / 5.64
brandLight: '#3D7BEA', // decorative only — a gradient stop, an illustration
brandDark: '#0845A8', // pressed fill; white on it 8.65:1
brandDeep: '#06296E',
brandSurface: '#EAF1FD', // brand on it 5.63:1; separation from a card 1.14
brandBorder: '#BBD0F6', // 1.56:1 against a card
// Deep navy, not the brand blue. It is a BACKDROP — the sign-in screen and
// the Home header — so it has to sit behind a blue button and white text
// without competing with either. Every on-brand tier clears AA on it with a
// wide margin.
brandGradient: ['#0A2F78', '#072260', '#051843'],
// The vivid one, for an object on the page. The lightest stop is the
// binding case for the translucent tiers: white @0.75 on #0F4CC9 is 4.78:1.
heroGradient: ['#0F4CC9', '#0B3FAE', '#082F86'],
/* On the navy wash. Each tier is validated against the LIGHTEST stop of
* both gradients, 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.
* Text directly on the wash may use any tier. Text on a FILL takes the
* solid tone, always. */
onBrandPrimary: '#FFFFFF', // 12.42:1 wash, 7.33 hero
onBrandSecondary: 'rgba(255, 255, 255, 0.85)', // body-safe on both
onBrandMuted: 'rgba(255, 255, 255, 0.75)', // 4.78:1 on the hero stop
onBrandSurface: 'rgba(255, 255, 255, 0.12)',
onBrandSurfaceStrong: 'rgba(255, 255, 255, 0.18)', // the ceiling for a
// fill carrying white
onBrandBorder: 'rgba(255, 255, 255, 0.24)',
/* Gold, for rewards and offers. Blue and gold is a pairing a bank has used
* for a century, and it keeps a promotional highlight visibly different
* from a success state. */
accent: '#876013', // 5.66 / 5.22 / 4.99 — and white-on-it 5.66:1
accentStrong: '#6B4C0F',
accentFill: '#E9B23C', // ink on it 8.77:1; WHITE ON IT IS 1.93:1
textOnAccentFill: '#0B1B3F', // 8.77: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.25 / 5.02
successSurface: '#E6F5ED',
successBorder: '#9FD8BC',
warning: '#955A00', // 5.61 / 5.18 / 4.96
warningSurface: '#FDF3E3',
warningBorder: '#EFCB8C',
danger: '#C52A1F', // 5.64 / 5.20 / 4.98
dangerSurface: '#FCEBE9',
dangerBorder: '#EFB4AE',
info: '#0F6CAA', // 5.61 / 5.18 / 4.95
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(11, 27, 63, 0.55)',
white: '#FFFFFF',
// A navy shadow rather than black: on a blue-grey ground a black shadow
// reads as dirt, a navy one reads as depth.
shadow: '#0B1B3F',
};
/* ════════════════════════════════════════════════════════════════════════════
* DARK
*
* Not the light ramp inverted. Three things genuinely change:
*
* 1. Every tone LIGHTENS. #0B57D0 on a dark ground is under 3:1, so the
* interactive brand becomes a lighter periwinkle blue 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 a deep midnight navy 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: '#0A0F1C',
backgroundElevated: '#121A2B',
backgroundGradient: ['#121A2B', '#0E1523', '#0A0F1C'],
surface: '#121A2B',
surfaceMuted: '#1A2338',
surfaceSunk: '#222D46', // non-text; only textPrimary 12.04, textSecondary
// 7.86 and brand 5.74 are legible on it
border: '#28344E', // 1.40:1 against a card
borderStrong: '#3A4866', // 1.90:1
textPrimary: '#EAF1F8', // 15.26 / 16.79 / 13.74
textSecondary: '#B6C6D6', // 9.96 / 10.96 / 8.97
textMuted: '#93A6B9', // 6.95 / 7.65 / 6.26
textOnFill: '#0B1420', // ink on `brand` 7.75:1 — white would fail
brand: '#7AA7FF', // 7.28 / 8.01 / 6.56
brandLight: '#A6C4FF',
brandDark: '#4F86F0', // pressed; ink on it 5.29:1
brandDeep: '#0A1A3D',
brandSurface: '#15264A', // brand on it 6.25:1; separation from a card 1.17
brandBorder: '#2A4478', // 1.82:1 against a card
// The wash stays a saturated navy in dark mode, a step brighter than the
// ground so the header band still reads as a band.
brandGradient: ['#14378A', '#0E296B', '#091B4A'],
// Lightest stop: white @0.75 on #1E4FB8 is 4.87:1.
heroGradient: ['#1E4FB8', '#163F99', '#0F2E73'],
onBrandPrimary: '#FFFFFF', // 10.81 wash, 7.33 hero
onBrandSecondary: 'rgba(255, 255, 255, 0.85)',
onBrandMuted: 'rgba(255, 255, 255, 0.75)',
onBrandSurface: 'rgba(255, 255, 255, 0.12)',
onBrandSurfaceStrong: 'rgba(255, 255, 255, 0.18)',
onBrandBorder: 'rgba(255, 255, 255, 0.24)',
accent: '#E8BC63', // 9.77 / 10.75 / 8.80
accentStrong: '#F0CE8A',
accentFill: '#E9B23C', // the same fill as light mode, and the same rule:
// it takes ink, never white
textOnAccentFill: '#0B1420', // 9.60:1. Note this is NOT `textPrimary`, which is
// near-white here and only 1.69:1 on the gold —
// the reason this role exists at all
accentSurface: '#2E2817', // accent on it 8.24:1; separation 1.19
accentBorder: '#5A4520',
success: '#4FC98D', // 8.34 / 9.17 / 7.51
successSurface: '#123026', // success on it 6.82:1; separation 1.22
successBorder: '#245C40',
warning: '#E3AC4E', // 8.51 / 9.38 / 7.66
warningSurface: '#2E2717', // warning on it 7.24:1
warningBorder: '#57431D',
danger: '#F2827A', // 6.83 / 7.51 / 6.15
dangerSurface: '#3F2228', // danger on it 5.61:1
dangerBorder: '#5E2E34',
info: '#63B0E3', // 7.32 / 8.06 / 6.60
infoSurface: '#132B3E', // info on it 6.13:1; separation 1.19
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: '#1A2A50' }, // 7.30 / 5.92 / 1.23
green: { tone: '#4FC98D', surface: '#123026' }, // 8.34 / 6.82 / 1.22
violet: { tone: '#C08CF0', surface: '#2A1F45' }, // 6.83 / 5.98 / 1.14
amber: { tone: '#E3AC4E', surface: '#2E2717' }, // 8.51 / 7.24 / 1.19
pink: { tone: '#F58AB4', surface: '#371F35' }, // 7.59 / 6.51 / 1.17
sky: { tone: '#63B0E3', surface: '#132B3E' }, // 7.32 / 6.13 / 1.19
teal: { tone: '#3FC5BC', surface: '#0F2E33' }, // 8.21 / 6.80 / 1.21
indigo: { tone: '#A5A0FB', surface: '#22224A' }, // 7.47 / 6.46 / 1.16
},
// Heavier than the light scrim: the sheet beneath it is dark too, so the
// scrim has less tonal distance to work with.
overlay: 'rgba(3, 7, 18, 0.72)',
white: '#FFFFFF',
shadow: '#000000',
};