372 lines
19 KiB
TypeScript
372 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 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',
|
|
};
|