291 lines
12 KiB
Dart
291 lines
12 KiB
Dart
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);
|
||
});
|
||
});
|
||
}
|