V3-16: visual identity (token-level pass)

Direction written before code, per the ticket's own step 1: safety orange stays primary (authentic to the domain, not decorative), a new instrument-blue tertiary is reserved for reference readings (max/average) vs the live figure, ground warmed fractionally, no new font, no motion -- both argued for directly rather than left undecided.

Instrument blue reuses RideMap's existing slow-speed gradient color rather than inventing a new one, so 'same colour, same meaning' holds between the map and stat rows. contrastRatio() implements WCAG 2.x directly and is asserted (11 tests, both themes, AA normal/large) rather than eyeballed. Applied to StatRow's new 'reference' flag on record screen and trip detail's Max/Avg speed rows -- a token-level + targeted pass, not an exhaustive re-skin, named as a deliberate scope reduction alongside the two criteria (outdoor sunlight verification, golden tests) this environment genuinely cannot satisfy.
This commit is contained in:
2026-08-17 19:50:08 -05:00
parent e4ffafe9e0
commit 1bcc6f1c5a
7 changed files with 209 additions and 10 deletions

82
test/theme_test.dart Normal file
View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/ui/theme.dart';
/// V3-16's acceptance criterion is "contrast ratios meet WCAG AA for body text" --
/// asserted directly here rather than trusted from a palette chosen by eye. AA is 4.5:1
/// for normal text, 3:1 for large text (18pt, or 14pt bold and up -- the headline
/// figures in `BigStat`).
void main() {
group('contrastRatio', () {
test('black on white is the maximum possible ratio, 21:1', () {
expect(
contrastRatio(const Color(0xFF000000), const Color(0xFFFFFFFF)),
closeTo(21.0, 0.01),
);
});
test('a colour against itself is 1:1', () {
const c = Color(0xFFFF5722);
expect(contrastRatio(c, c), closeTo(1.0, 0.01));
});
test('is symmetric regardless of argument order', () {
const a = Color(0xFF101418);
const b = Color(0xFFF2F5F8);
expect(contrastRatio(a, b), contrastRatio(b, a));
});
});
group('pocketed theme (WCAG AA)', () {
final colors = ripprTheme().colorScheme;
test('body text on the ground meets AA normal text (4.5:1)', () {
expect(contrastRatio(colors.onSurface, ripprBackground), greaterThanOrEqualTo(4.5));
});
test('body text on a surface card meets AA normal text (4.5:1)', () {
expect(contrastRatio(colors.onSurface, colors.surface), greaterThanOrEqualTo(4.5));
});
test('the primary accent on the ground meets AA large text (3:1)', () {
// The accent is used for headline figures and buttons, not small body copy --
// held to the large-text threshold, matching how it's actually used on screen.
expect(contrastRatio(colors.primary, ripprBackground), greaterThanOrEqualTo(3.0));
});
test('the instrument-blue tertiary on the ground meets AA large text (3:1)', () {
expect(contrastRatio(colors.tertiary, ripprBackground), greaterThanOrEqualTo(3.0));
});
test('the instrument-blue tertiary on a surface card meets AA large text (3:1)', () {
expect(contrastRatio(colors.tertiary, colors.surface), greaterThanOrEqualTo(3.0));
});
});
group('mounted theme (WCAG AA)', () {
final colors = ripprMountedTheme().colorScheme;
test('body text on the ground meets AA normal text (4.5:1)', () {
expect(
contrastRatio(colors.onSurface, ripprMountedBackground),
greaterThanOrEqualTo(4.5),
);
});
test('the primary accent on the ground meets AA large text (3:1) -- this is the '
'theme sunlight legibility actually depends on', () {
expect(
contrastRatio(colors.primary, ripprMountedBackground),
greaterThanOrEqualTo(3.0),
);
});
test('the instrument-blue tertiary meets AA normal text on the ground '
'(darkened further than the pocketed theme specifically for this)', () {
expect(
contrastRatio(colors.tertiary, ripprMountedBackground),
greaterThanOrEqualTo(4.5),
);
});
});
}