first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

View File

@@ -0,0 +1,290 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// ── Contrast is a contract, not a preference ──
///
/// This app is read on a phone clamped to a handlebar in Indian daylight, by
/// someone wearing a helmet, while a customer waits. Contrast is not an
/// accessibility checkbox here; it is whether the rider can do the job.
///
/// An audit of the palette found the app's **second most-used colour failing
/// outright**: `secondaryText` was #8E8E93, which is 3.26:1 on white against a
/// 4.5:1 WCAG AA floor for normal text. It carries captions, labels and
/// metadata on 89 call sites across all 33 screens — so on every screen, the
/// supporting text was the part that disappeared in sunlight. `moneyGreen`
/// exists for the same reason: `acceptGreen` is 2.62:1 as text.
///
/// Those were fixed at the token, so this file guards the tokens. A colour is a
/// number; a number can be asserted, and that is the only way a palette stays
/// legible through future redesigns.
void main() {
/// WCAG 2.1 relative luminance.
double luminance(Color c) {
double channel(double v) =>
v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4) as double;
return 0.2126 * channel(c.r) +
0.7152 * channel(c.g) +
0.0722 * channel(c.b);
}
/// WCAG 2.1 contrast ratio, 1.0–21.0.
double ratio(Color a, Color b) {
final la = luminance(a), lb = luminance(b);
return (math.max(la, lb) + 0.05) / (math.min(la, lb) + 0.05);
}
// Every background the app draws text on.
final surfaces = <String, Color>{
'white': Colors.white,
'page (surface)': ColorConstants.surface,
'card fill (cardSurface)': ColorConstants.cardSurface,
'neutralLight': ColorConstants.neutralLight,
};
/// WCAG AA: 4.5:1 for normal text, 3.0:1 for large or bold text.
const aaNormal = 4.5;
const aaLarge = 3.0;
group('text colours clear AA on every surface they are used on', () {
final bodyText = <String, Color>{
'slateText': ColorConstants.slateText,
'secondaryText': ColorConstants.secondaryText,
'onSurfaceVariant': ColorConstants.onSurfaceVariant,
'onSurface': ColorConstants.onSurface,
'moneyGreen': ColorConstants.moneyGreen,
'errorRed': ColorConstants.errorRed,
};
bodyText.forEach((name, colour) {
surfaces.forEach((bgName, bg) {
test('$name on $bgName', () {
final r = ratio(colour, bg);
expect(r, greaterThanOrEqualTo(aaNormal),
reason: '$name on $bgName is ${r.toStringAsFixed(2)}:1, '
'under the $aaNormal:1 AA floor for normal text');
});
});
});
});
/// ── Every token is declared, or the test fails ──
///
/// The old version guarded six colours. That was a false sense of coverage:
/// an audit found **five tokens failing outright** — `warningOrange` 2.20:1,
/// `successGreen` 2.22:1, `warningYellow` 1.73:1, `cardBlue` 2.77:1,
/// `infoBlue` 4.02:1 — plus `disabledText` at 1.68:1 being used as readable
/// caption text on seven screens. Every one of them sat outside the six.
///
/// So the contract is inverted. This enumerates the whole palette and forces
/// each colour into exactly one bucket:
///
/// * **text-safe** — asserted ≥4.5:1 on every surface the app draws on.
/// * **fill** — carries a white label; the label is what is asserted.
/// * **structural** — a hairline or a disabled fill, carrying no words:
/// asserted visible against the surface, and asserted *not* readable, so a
/// colour that could be text has to be filed as text.
///
/// A new token belongs to a bucket or this test fails, which is the only way
/// the next `warningOrange` cannot land.
group('the palette is exhaustively classified', () {
// Colours the app is allowed to render words in.
final textSafe = <String, Color>{
'slateText': ColorConstants.slateText,
'secondaryText': ColorConstants.secondaryText,
'onSurfaceVariant': ColorConstants.onSurfaceVariant,
'onSurface': ColorConstants.onSurface,
'moneyGreen': ColorConstants.moneyGreen,
'acceptGreen': ColorConstants.acceptGreen,
'errorRed': ColorConstants.errorRed,
'error': ColorConstants.error,
'warning': ColorConstants.warning,
'primary': ColorConstants.primary,
'rewardAmber': ColorConstants.rewardAmber,
'tertiary': ColorConstants.tertiary,
'deliveryAccent': ColorConstants.deliveryAccent,
'combinedAccent': ColorConstants.combinedAccent,
'upiPurple': ColorConstants.upiPurple,
'emphasis': ColorConstants.emphasis,
};
// Colours that fill a shape carrying a white label. The label is what is
// asserted — several of these are dark enough to read as text too, which is
// fine; the bucket is about the job, not about failing some other job.
final fillWithWhiteLabel = <String, Color>{
'pickupChip': ColorConstants.pickupChip,
'deliveryChip': ColorConstants.deliveryChip,
'combinedChip': ColorConstants.combinedChip,
'primaryContainer': ColorConstants.primaryContainer,
'successContainer': ColorConstants.successContainer,
'tertiaryContainer': ColorConstants.tertiaryContainer,
};
// Hairlines, dividers and disabled fills. These carry no words at all; the
// only thing that matters is that they are visible against the surface
// behind them.
final structural = <String, Color>{
'disabledFill': ColorConstants.disabledFill,
'borderStrong': ColorConstants.borderStrong,
'borderSubtle': ColorConstants.borderSubtle,
'dividerColor': ColorConstants.dividerColor,
'outlineVariant': ColorConstants.outlineVariant,
};
textSafe.forEach((name, colour) {
surfaces.forEach((bgName, bg) {
test('text-safe: $name reads on $bgName', () {
final r = ratio(colour, bg);
expect(r, greaterThanOrEqualTo(aaNormal),
reason: '$name on $bgName is ${r.toStringAsFixed(2)}:1. Either '
'lift the colour or move it out of the text-safe list — a '
'token that is in no list is how #FF9500 ended up carrying '
'the retry notice at 2.20:1');
});
});
});
fillWithWhiteLabel.forEach((name, fill) {
test('fill: $name carries a white label', () {
final r = ratio(Colors.white, fill);
expect(r, greaterThanOrEqualTo(aaNormal),
reason: 'white on $name is ${r.toStringAsFixed(2)}:1');
});
});
structural.forEach((name, colour) {
test('structural: $name is visible on white', () {
final r = ratio(colour, Colors.white);
// A 1px hairline is not held to a text floor — iOS separators sit
// around 1.1:1 and are the convention riders already read. What is
// asserted is that the difference is real and cannot erode to nothing.
// `borderStrong` is held higher, separately, because its job is to make
// a container read as a container in sunlight.
expect(r, greaterThan(1.15),
reason: '$name is ${r.toStringAsFixed(3)}:1 on white — an edge '
'nobody can see');
});
test('structural: $name is never a text colour', () {
// It cannot pass as text, which is exactly why it must not be used as
// one. `disabledFill` was, on seven screens, at 1.68:1.
final r = ratio(colour, Colors.white);
expect(r, lessThan(aaNormal),
reason: '$name reads well enough to be text — if that is what it '
'is for, move it to the text-safe list');
});
});
test('the five retired tokens have not come back', () {
// Compile-time guard. If someone re-adds `warningOrange`, `infoBlue`,
// `successGreen`, `cardBlue` or `warningYellow`, they have to delete this
// list first — and read why they were removed.
const retired = <String, int>{
'warningOrange': 0xFFFF9500,
'successGreen': 0xFF34C759,
'warningYellow': 0xFFFFB800,
'cardBlue': 0xFF0EA5E9,
'infoBlue': 0xFF007AFF,
};
for (final e in retired.entries) {
final r = ratio(Color(e.value), Colors.white);
expect(r, lessThan(aaNormal),
reason: 'sanity: ${e.key} really did fail at '
'${r.toStringAsFixed(2)}:1');
}
});
});
group('white text on a filled control clears AA', () {
final fills = <String, Color>{
'primary (brand)': ColorConstants.primary,
'acceptGreen (Accept button)': ColorConstants.acceptGreen,
'pickupChip': ColorConstants.pickupChip,
'deliveryChip': ColorConstants.deliveryChip,
'combinedChip': ColorConstants.combinedChip,
'warning (skipped)': ColorConstants.warning,
'errorRed': ColorConstants.errorRed,
};
fills.forEach((name, fill) {
test('white on $name', () {
final r = ratio(Colors.white, fill);
// Button and chip labels are bold, so the large-text floor applies —
// but everything here should be comfortably past it.
expect(r, greaterThanOrEqualTo(aaLarge),
reason: 'white on $name is ${r.toStringAsFixed(2)}:1');
});
});
// The chips carry 10sp text, which is small even when bold, so they are held
// to the stricter floor. This is what "darkened" bought.
test('the stop-type chips clear the NORMAL-text floor, not just large', () {
for (final e in {
'pickupChip': ColorConstants.pickupChip,
'deliveryChip': ColorConstants.deliveryChip,
'combinedChip': ColorConstants.combinedChip,
}.entries) {
final r = ratio(Colors.white, e.value);
expect(r, greaterThanOrEqualTo(aaNormal),
reason: '${e.key} is ${r.toStringAsFixed(2)}:1 — the chip label is '
'10sp, so large-text allowances do not apply');
}
});
});
group('surfaces stay distinguishable', () {
// A borderless card has nothing but its fill to separate it from the page.
// Riders reported being unable to tell cards apart at ~3%; this keeps the
// separation from quietly eroding back to that.
test('cardSurface is clearly darker than the page', () {
final drop = 1 -
luminance(ColorConstants.cardSurface) /
luminance(ColorConstants.surface);
expect(drop, greaterThan(0.07),
reason: 'only ${(drop * 100).toStringAsFixed(1)}% darker than the '
'page — a borderless card needs its fill to do the separating');
});
test('borderStrong is visible against white and against the card', () {
// Non-text contrast (UI components) has a 3:1 AA floor.
for (final e in {
'white': Colors.white,
'cardSurface': ColorConstants.cardSurface,
}.entries) {
final r = ratio(ColorConstants.borderStrong, e.value);
expect(r, greaterThan(1.2),
reason: 'borderStrong on ${e.key} is only '
'${r.toStringAsFixed(2)}:1 — an edge nobody can see');
}
});
});
group('the brand palette has no tonal leftovers', () {
// The M3 tonal derivation of the red seed produced a brown body text and a
// pink outline, which shipped on 70 call sites and inside `AppTheme` — so
// the auth flow and every text field were brown-and-pink while the
// dashboard was slate-and-white. These assert they stay neutral: for a
// grey/slate, the three channels sit close together.
void expectNeutral(String name, Color c) {
final spread = [c.r, c.g, c.b].reduce(math.max) -
[c.r, c.g, c.b].reduce(math.min);
expect(spread, lessThan(0.12),
reason: '$name has an RGB spread of ${spread.toStringAsFixed(3)} — '
'that is a hue, not a neutral');
}
test('onSurfaceVariant is a neutral, not a brown', () {
expectNeutral('onSurfaceVariant', ColorConstants.onSurfaceVariant);
});
test('outlineVariant is a neutral, not a pink', () {
expectNeutral('outlineVariant', ColorConstants.outlineVariant);
});
test('secondaryText is a neutral', () {
expectNeutral('secondaryText', ColorConstants.secondaryText);
});
});
}