first commit
This commit is contained in:
290
test/contrast_contract_test.dart
Normal file
290
test/contrast_contract_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user