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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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); }); }); }