Files
doormile_milderapp/test/contrast_contract_test.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

334 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
}