Update CBS Mobile App codebase
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reports feature keys that nothing in the app actually gates.
|
||||
*
|
||||
* Why this exists: a key can be declared, configured, published by the server
|
||||
* and shown in the toggle screens while no UI consults it. Switching it off then
|
||||
* appears to work and changes nothing — the worst possible failure for a kill
|
||||
* switch, because it is silent and is only discovered when someone needs it.
|
||||
*
|
||||
* A key counts as gated when it is referenced anywhere outside its own
|
||||
* declaration (`featureKeys.ts`) and the static config (`featureConfig.ts`).
|
||||
* Parent keys need no direct reference: the service resolves ancestors, so a key
|
||||
* with a gated descendant is effective through the hierarchy and is reported
|
||||
* separately rather than as a fault.
|
||||
*
|
||||
* node scripts/audit-features.js # report
|
||||
* node scripts/audit-features.js --strict # exit 1 if any key is unwired
|
||||
*
|
||||
* `--strict` is what CI should run once the list below is empty.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SRC = path.join(ROOT, 'src');
|
||||
const KEYS_FILE = path.join(SRC, 'constants', 'featureKeys.ts');
|
||||
const EXCLUDED = new Set(['featureKeys.ts', 'featureConfig.ts']);
|
||||
|
||||
/**
|
||||
* Keys knowingly declared ahead of the UI that will consult them, with the
|
||||
* reason. Listed here so the audit stays useful — an unreviewed key shows up as
|
||||
* a finding, while these show as accepted.
|
||||
*
|
||||
* Remove an entry when you wire its UI up. Do not add one to quieten the audit
|
||||
* unless the feature genuinely has no screen yet.
|
||||
*/
|
||||
const ACCEPTED_UNWIRED = {
|
||||
'auth.login.forgotPassword':
|
||||
'No forgot-MPIN flow exists by policy — a reset needs a branch visit.',
|
||||
'auth.login.createAccount': 'Account opening is not in the customer app yet.',
|
||||
'home.services': 'Home services grid is commented out in HomeScreen.',
|
||||
'home.services.transfer': 'Part of the commented-out home services grid.',
|
||||
'home.services.selfTransfer': 'Part of the commented-out home services grid.',
|
||||
'home.services.billPay': 'Part of the commented-out home services grid.',
|
||||
'home.services.collectRequest': 'Part of the commented-out home services grid.',
|
||||
'home.services.managePayee': 'Part of the commented-out home services grid.',
|
||||
'home.services.support': 'Part of the commented-out home services grid.',
|
||||
'home.services.insurance': 'Part of the commented-out home services grid.',
|
||||
'home.services.deposits': 'Part of the commented-out home services grid.',
|
||||
'home.services.goldLoan': 'Part of the commented-out home services grid.',
|
||||
'home.insights': 'InsightsCard component exists but is not rendered anywhere.',
|
||||
'home.promoBanner': 'PromoBanner component exists but is not rendered anywhere.',
|
||||
'payments.upi': 'No UPI screens have been built.',
|
||||
'payments.upi.scan': 'No UPI screens have been built.',
|
||||
};
|
||||
|
||||
const walk = (dir, files = []) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walk(full, files);
|
||||
} else if (/\.tsx?$/.test(entry.name) && !EXCLUDED.has(entry.name)) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const source = fs.readFileSync(KEYS_FILE, 'utf8');
|
||||
const declarations = [...source.matchAll(/^\s+([A-Z_0-9]+) = '([a-zA-Z0-9.]+)',/gm)].map((m) => ({
|
||||
member: m[1],
|
||||
path: m[2],
|
||||
}));
|
||||
|
||||
if (declarations.length === 0) {
|
||||
console.error('No feature keys found — has featureKeys.ts moved or changed shape?');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const corpus = walk(SRC)
|
||||
.map((file) => fs.readFileSync(file, 'utf8'))
|
||||
.join('\n');
|
||||
|
||||
const referenced = new Set(
|
||||
[...corpus.matchAll(/FeatureKey\.([A-Z_0-9]+)/g)].map((m) => m[1]),
|
||||
);
|
||||
|
||||
const gatedPaths = new Set(
|
||||
declarations.filter((d) => referenced.has(d.member)).map((d) => d.path),
|
||||
);
|
||||
|
||||
const viaHierarchy = [];
|
||||
const unwired = [];
|
||||
const accepted = [];
|
||||
|
||||
for (const declaration of declarations) {
|
||||
if (gatedPaths.has(declaration.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ([...gatedPaths].some((gated) => gated.startsWith(`${declaration.path}.`))) {
|
||||
viaHierarchy.push(declaration);
|
||||
} else if (ACCEPTED_UNWIRED[declaration.path]) {
|
||||
accepted.push(declaration);
|
||||
} else {
|
||||
unwired.push(declaration);
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padEnd(width);
|
||||
|
||||
console.log(`\nFeature gating audit — ${declarations.length} declared keys\n`);
|
||||
console.log(` gated directly ${gatedPaths.size}`);
|
||||
console.log(` via hierarchy ${viaHierarchy.length} (parent of a gated key)`);
|
||||
console.log(` accepted unwired ${accepted.length} (no UI yet, reason recorded)`);
|
||||
console.log(` UNWIRED ${unwired.length}`);
|
||||
|
||||
if (accepted.length > 0) {
|
||||
console.log('\nAccepted — declared ahead of their UI:');
|
||||
for (const { path: keyPath } of accepted.sort((a, b) => a.path.localeCompare(b.path))) {
|
||||
console.log(` ${pad(keyPath, 34)} ${ACCEPTED_UNWIRED[keyPath]}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (unwired.length > 0) {
|
||||
console.log('\nUNWIRED — declared, configurable, but nothing reads them.');
|
||||
console.log('Switching one of these off will appear to work and change nothing.\n');
|
||||
for (const { path: keyPath, member } of unwired.sort((a, b) => a.path.localeCompare(b.path))) {
|
||||
console.log(` ${pad(keyPath, 40)} FeatureKey.${member}`);
|
||||
}
|
||||
console.log('\nEither gate the UI it should control, or record it in ACCEPTED_UNWIRED');
|
||||
console.log('in this script with the reason.\n');
|
||||
} else {
|
||||
console.log('\nEvery declared key is either gated or has a recorded reason.\n');
|
||||
}
|
||||
|
||||
if (process.argv.includes('--strict') && unwired.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Measures every text-on-surface pairing in both colour ramps and fails if one
|
||||
* drops under AA.
|
||||
*
|
||||
* node scripts/checkContrast.js
|
||||
*
|
||||
* Run it after changing anything in src/theme/palette.ts. The ratios written
|
||||
* in the comments there came from this script; if you change a value and do
|
||||
* not re-run it, those comments become fiction.
|
||||
*
|
||||
* It reads palette.ts by parsing it rather than importing it, so it needs no
|
||||
* TypeScript toolchain and can run in a git hook or in CI as plain node.
|
||||
*
|
||||
* Exit code 0 = every pairing passes. 1 = at least one failure, listed.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PALETTE_PATH = path.join(__dirname, '..', 'src', 'theme', 'palette.ts');
|
||||
|
||||
/* ── WCAG 2.1 relative luminance and contrast ratio ──────────────────────── */
|
||||
|
||||
const toRgb = (color) => {
|
||||
const rgba = color.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
|
||||
if (rgba) return [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])];
|
||||
|
||||
let h = color.replace('#', '').trim();
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
||||
};
|
||||
|
||||
/** The alpha of an rgba() string, or 1 for an opaque colour. */
|
||||
const alphaOf = (color) => {
|
||||
const m = color.match(/rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)/i);
|
||||
return m ? Number(m[1]) : 1;
|
||||
};
|
||||
|
||||
const channel = (c) => {
|
||||
const s = c / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
|
||||
const luminance = (color) => {
|
||||
const [r, g, b] = toRgb(color);
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||
};
|
||||
|
||||
/**
|
||||
* Composite a translucent colour over an opaque backdrop.
|
||||
*
|
||||
* Necessary because the on-brand tiers are translucent white, and a contrast
|
||||
* ratio is only meaningful between two opaque colours — the number that
|
||||
* matters is the one the composite actually paints.
|
||||
*/
|
||||
const composite = (color, backdrop) => {
|
||||
const a = alphaOf(color);
|
||||
if (a === 1) return color;
|
||||
const f = toRgb(color);
|
||||
const b = toRgb(backdrop);
|
||||
const mixed = f.map((c, i) => Math.round(c * a + b[i] * (1 - a)));
|
||||
return `#${mixed.map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
||||
};
|
||||
|
||||
const ratio = (fg, bg) => {
|
||||
const a = luminance(composite(fg, bg));
|
||||
const b = luminance(bg);
|
||||
const [hi, lo] = a > b ? [a, b] : [b, a];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
|
||||
/* ── parse the two ramps out of palette.ts ───────────────────────────────── */
|
||||
|
||||
const parseRamp = (source, name) => {
|
||||
const start = source.indexOf(`export const ${name}: Palette = {`);
|
||||
if (start === -1) throw new Error(`Could not find the "${name}" ramp in palette.ts`);
|
||||
|
||||
// Walk braces so a nested value cannot end the block early.
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
for (let i = source.indexOf('{', start); i < source.length; i += 1) {
|
||||
if (source[i] === '{') depth += 1;
|
||||
else if (source[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const body = source.slice(start, end);
|
||||
const ramp = {};
|
||||
|
||||
// key: '#hex' | 'rgba(...)'
|
||||
for (const [, key, value] of body.matchAll(/^\s*(\w+):\s*'([^']+)'/gm)) {
|
||||
ramp[key] = value;
|
||||
}
|
||||
// category: { blue: { tone: '..', surface: '..' }, ... }
|
||||
ramp.category = {};
|
||||
for (const [, hue, tone, surface] of body.matchAll(
|
||||
/(\w+):\s*\{\s*tone:\s*'([^']+)',\s*surface:\s*'([^']+)'\s*\}/g,
|
||||
)) {
|
||||
ramp.category[hue] = { tone, surface };
|
||||
}
|
||||
|
||||
// key: ['a', 'b', 'c']
|
||||
for (const [, key, list] of body.matchAll(/^\s*(\w+):\s*\[([^\]]+)\]/gm)) {
|
||||
ramp[key] = list.match(/'([^']+)'/g).map((s) => s.slice(1, -1));
|
||||
}
|
||||
|
||||
return ramp;
|
||||
};
|
||||
|
||||
/* ── the rules ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 4.6 rather than 4.5: a 0.1 margin over AA so rounding on a device cannot
|
||||
* drop a pairing under the bar it was signed off at.
|
||||
*/
|
||||
const AA = 4.6;
|
||||
/** Agent screens are used outdoors and hold to a higher bar. */
|
||||
const AA_AGENT = 7;
|
||||
/**
|
||||
* A tint surface has to be this distinguishable from a plain card, or a banner
|
||||
* reads as untinted.
|
||||
*
|
||||
* 1.06 is the floor the approved light ramp actually achieves — its
|
||||
* `accentSurface` (#FEF7EA on white) sits at 1.066, the quietest tint in the
|
||||
* design. The bar is set to that rather than to the ramp's 1.123 average so it
|
||||
* asserts the real contract instead of failing the shipped design; it still
|
||||
* catches a genuinely invisible tint, which is what this check is for.
|
||||
*/
|
||||
const TINT_SEPARATION = 1.06;
|
||||
/** A border has to be at least this visible against the card it sits on. */
|
||||
const BORDER_VISIBILITY = 1.25;
|
||||
/** Two adjacent surface fills need at least this much step between them. */
|
||||
const SURFACE_STEP = 1.06;
|
||||
|
||||
const TEXT_SURFACES = ['surface', 'background', 'surfaceMuted'];
|
||||
const TEXT_TONES = ['textPrimary', 'textSecondary', 'textMuted'];
|
||||
const SEMANTIC = ['success', 'warning', 'danger', 'info'];
|
||||
const ALL_TONES = ['brand', 'accent', ...SEMANTIC];
|
||||
|
||||
const checkRamp = (ramp, rampName) => {
|
||||
const results = [];
|
||||
const add = (ok, label, value, bar) =>
|
||||
results.push({ ok, label, value, bar, ramp: rampName });
|
||||
|
||||
const at = (fg, bg, bar, label) => add(ratio(ramp[fg], ramp[bg]) >= bar, label, ratio(ramp[fg], ramp[bg]), bar);
|
||||
|
||||
// Body text and every tone, on all three text-bearing surfaces.
|
||||
for (const tone of [...TEXT_TONES, ...ALL_TONES]) {
|
||||
for (const surface of TEXT_SURFACES) {
|
||||
at(tone, surface, AA, `${tone} on ${surface}`);
|
||||
}
|
||||
}
|
||||
|
||||
// surfaceSunk is non-text, but these three are documented as legible on it.
|
||||
for (const tone of ['textPrimary', 'textSecondary', 'brand']) {
|
||||
at(tone, 'surfaceSunk', AA, `${tone} on surfaceSunk`);
|
||||
}
|
||||
|
||||
// Each tone on its own tint surface — a banner shows both at once.
|
||||
for (const tone of ALL_TONES) {
|
||||
const surface = tone === 'brand' ? 'brandSurface' : `${tone}Surface`;
|
||||
at(tone, surface, AA, `${tone} on ${surface}`);
|
||||
}
|
||||
|
||||
// Body copy on every tint surface, for a banner's message text.
|
||||
for (const tone of ALL_TONES) {
|
||||
const surface = tone === 'brand' ? 'brandSurface' : `${tone}Surface`;
|
||||
at('textPrimary', surface, AA, `textPrimary on ${surface}`);
|
||||
at('textSecondary', surface, AA, `textSecondary on ${surface}`);
|
||||
}
|
||||
|
||||
// The fill rule: whatever `textOnFill` is for this ramp must work on every
|
||||
// saturated fill. This is the check that catches a white label in dark mode.
|
||||
//
|
||||
// `accentFill` is the documented exception and is checked separately below.
|
||||
// It is the vivid brass, and it takes ink in BOTH ramps — white on it is
|
||||
// 2.26:1. Including it here would demand that `textOnFill` work on it, which
|
||||
// is exactly the thing the palette says not to do.
|
||||
for (const tone of ['brand', ...SEMANTIC]) {
|
||||
at('textOnFill', tone, AA, `textOnFill on ${tone} fill`);
|
||||
}
|
||||
at('textOnAccentFill', 'accentFill', AA, 'textOnAccentFill on accentFill');
|
||||
|
||||
// Agent screens: the primary pairing has to clear the outdoor bar.
|
||||
at('textPrimary', 'surface', AA_AGENT, 'textPrimary on surface (agent bar)');
|
||||
|
||||
// A tint surface must be distinguishable from a plain card.
|
||||
for (const tone of ALL_TONES) {
|
||||
const surface = tone === 'brand' ? 'brandSurface' : `${tone}Surface`;
|
||||
at(surface, 'surface', TINT_SEPARATION, `${surface} distinct from surface`);
|
||||
}
|
||||
|
||||
// Borders have to be visible against the card they edge.
|
||||
for (const border of ['border', 'borderStrong', 'brandBorder', 'accentBorder',
|
||||
...SEMANTIC.map((t) => `${t}Border`)]) {
|
||||
at(border, 'surface', BORDER_VISIBILITY, `${border} visible on surface`);
|
||||
}
|
||||
|
||||
// Adjacent surface fills need a visible step, which is what carries depth
|
||||
// once the shadow stops doing anything (Android, and dark mode generally).
|
||||
at('background', 'surface', SURFACE_STEP, 'background / surface step');
|
||||
at('surface', 'surfaceMuted', SURFACE_STEP, 'surface / surfaceMuted step');
|
||||
at('surfaceMuted', 'surfaceSunk', SURFACE_STEP, 'surfaceMuted / surfaceSunk step');
|
||||
|
||||
// Categorical hues: the icon tone has to be legible on a plain card AND on
|
||||
// its own tint chip, and the chip has to be visible against the card. This
|
||||
// is what the old `${hue}15` alpha suffix could not deliver in dark mode.
|
||||
for (const [hue, pair] of Object.entries(ramp.category)) {
|
||||
const onCard = ratio(pair.tone, ramp.surface);
|
||||
add(onCard >= AA, `category.${hue} tone on surface`, onCard, AA);
|
||||
const onTint = ratio(pair.tone, pair.surface);
|
||||
add(onTint >= AA, `category.${hue} tone on its tint`, onTint, AA);
|
||||
const sep = ratio(pair.surface, ramp.surface);
|
||||
add(sep >= TINT_SEPARATION, `category.${hue} tint distinct from surface`, sep, TINT_SEPARATION);
|
||||
}
|
||||
|
||||
// The on-brand tiers, against the LIGHTEST stop of the wash — the worst case.
|
||||
const wash = ramp.brandGradient[0];
|
||||
for (const tier of ['onBrandPrimary', 'onBrandSecondary', 'onBrandMuted']) {
|
||||
const r = ratio(ramp[tier], wash);
|
||||
add(r >= AA, `${tier} on the brand wash`, r, AA);
|
||||
}
|
||||
// And solid white on each translucent fill — the tiers do not stack, so a
|
||||
// fill always carries the solid tone.
|
||||
for (const fill of ['onBrandSurface', 'onBrandSurfaceStrong']) {
|
||||
const composited = composite(ramp[fill], wash);
|
||||
const r = ratio(ramp.onBrandPrimary, composited);
|
||||
add(r >= AA, `onBrandPrimary on ${fill}`, r, AA);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/* ── run ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const source = fs.readFileSync(PALETTE_PATH, 'utf8');
|
||||
const ramps = { light: parseRamp(source, 'light'), dark: parseRamp(source, 'dark') };
|
||||
|
||||
// Both ramps must declare the same keys — TypeScript enforces this at compile
|
||||
// time, but this script also runs standalone, and a missing key here would
|
||||
// otherwise show up as a confusing NaN.
|
||||
const lightKeys = Object.keys(ramps.light).sort();
|
||||
const darkKeys = Object.keys(ramps.dark).sort();
|
||||
const missing = [
|
||||
...lightKeys.filter((k) => !darkKeys.includes(k)).map((k) => `dark is missing "${k}"`),
|
||||
...darkKeys.filter((k) => !lightKeys.includes(k)).map((k) => `light is missing "${k}"`),
|
||||
];
|
||||
|
||||
const all = [...checkRamp(ramps.light, 'light'), ...checkRamp(ramps.dark, 'dark')];
|
||||
const failures = all.filter((r) => !r.ok);
|
||||
|
||||
for (const name of ['light', 'dark']) {
|
||||
const rows = all.filter((r) => r.ramp === name);
|
||||
const bad = rows.filter((r) => !r.ok);
|
||||
console.log(`\n${name.toUpperCase()} ${rows.length} pairings, ${bad.length} failing`);
|
||||
for (const row of bad) {
|
||||
console.log(` FAIL ${row.label.padEnd(44)} ${row.value.toFixed(2)} (needs ${row.bar})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length) {
|
||||
console.log('\nRAMP MISMATCH');
|
||||
missing.forEach((m) => console.log(` ${m}`));
|
||||
}
|
||||
|
||||
if (failures.length || missing.length) {
|
||||
console.log(`\n${failures.length} contrast failure(s), ${missing.length} mismatch(es).`);
|
||||
console.log('Adjust the value in src/theme/palette.ts — do not delete the check.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nAll ${all.length} pairings pass in both ramps.`);
|
||||
Reference in New Issue
Block a user