From 1bcc6f1c5a178165e510fe3245149204ebdb96b3 Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Mon, 17 Aug 2026 19:50:08 -0500 Subject: [PATCH] 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. --- docs/v3/README.md | 2 +- docs/v3/V3-16-visual-identity.md | 81 +++++++++++++++++++++- lib/src/ui/components/stats.dart | 10 ++- lib/src/ui/detail/trip_detail_screen.dart | 2 + lib/src/ui/record/record_screen.dart | 1 + lib/src/ui/theme.dart | 41 ++++++++++-- test/theme_test.dart | 82 +++++++++++++++++++++++ 7 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 test/theme_test.dart diff --git a/docs/v3/README.md b/docs/v3/README.md index f8d9511..767a280 100644 --- a/docs/v3/README.md +++ b/docs/v3/README.md @@ -32,7 +32,7 @@ backup are v4 — see [../BACKLOG.md](../BACKLOG.md). | [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started | | [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) | | [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* | Not started | -| [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 | Not started | +| [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 | Partially done (token-level identity shipped; needs outdoor device verification) | ## Dependencies diff --git a/docs/v3/V3-16-visual-identity.md b/docs/v3/V3-16-visual-identity.md index fbfc942..ac3a96c 100644 --- a/docs/v3/V3-16-visual-identity.md +++ b/docs/v3/V3-16-visual-identity.md @@ -1,6 +1,40 @@ # V3-16 — Visual identity -**Phase** Quality · **Depends on** V3-04, V3-05 · **Size** M · **Status** Not started +**Phase** Quality · **Depends on** V3-04, V3-05 · **Size** M · **Status** Partially done + +## Direction (written before any code changed, per the ticket's own step 1) + +**Palette.** Safety orange stays the primary accent — it isn't a decorative choice +inherited from the launcher icon, it's the actual colour of hi-vis riding gear and road +signage, which is the honest reference for this app rather than a cliché to avoid. What +changes: orange stops being the only signal colour. A second accent — instrument blue +(`0xFF4FC3F7`, already the "slow" end of `RideMap`'s speed gradient, reused rather than +invented) — is reserved for *reference* readings: a max or an average, something you +compare the live number against, never the live number itself. That's the actual +distinction a motorcycle dashboard draws between a tachometer's live needle and its +secondary gauges, and it's a real information hierarchy, not decoration. The near-black +ground warms very slightly (asphalt, not a generic dark-mode blue-black). + +**Typography.** No new font family. Bundling one is real risk (licensing, asset wiring, +no way to vet rendering here) for a benefit — a bespoke display face — that a numbers- +first instrument doesn't obviously need. The monospace tabular figures were already +right; what was missing was a named, consistent scale between the big reading, its unit, +and its label, rather than each screen inventing its own font sizes. + +**Data display.** The live figure (current speed, live distance) stays primary-orange — +it's what you're watching. Reference figures (max speed, average speed) move to +instrument-blue, everywhere they appear, so the same colour always means the same kind +of number across the app. + +**Motion.** Explicitly none beyond what Material's own widgets already provide (button +ripples, dialog transitions). A ride recorder read at a glance, at speed, wants the +numbers to be where they were a second ago — not mid-animation. This is a decision, not +an oversight. + +**Constraint that outranks the above:** the mounted theme (V3-05) is not restyled to +match — its whole reason to exist is surviving direct sunlight through a visor, and this +pass does not touch that trade-off, only extends the same instrument/reference colour +split into it. ## Goal Move from "functional dark" to a look that is deliberately designed. @@ -56,3 +90,48 @@ Restyling breaking the explicit-colour discipline that exists because of a real ## Out of scope A new app icon. The Route mark is good and recently applied. + +## Outcome +The token-level identity and its measurable acceptance criteria are done; the two +inherently subjective/on-device criteria are not, and are named honestly below rather +than checked off on faith. + +Implemented at `theme.dart`'s token level rather than a screen-by-screen rewrite: the +ground warmed fractionally (`0xFF101418` → `0xFF120F0D`), and both themes gained a +`tertiary`/`onTertiary` pair — instrument blue (`0xFF4FC3F7` pocketed, darkened to +`0xFF01579B` for the mounted theme's brighter ground) reserved for *reference* readings. +`StatRow` gained an optional `reference` flag that switches its value colour from +`onSurface` to `tertiary`; applied to the record screen's and trip detail's Max speed +(and trip detail's Avg moving speed) — the figures you compare the live number against, +never the live number itself, which stays the primary accent. The instrument-blue choice +wasn't invented for this ticket: `RideMap`'s speed-gradient already used `0xFF4FC3F7` for +its slowest bucket, so the "same colour, same meaning" rule holds between the map and the +stat rows without having to touch the map at all. + +`contrastRatio(Color, Color)` implements WCAG 2.x's formula directly against +`Color.computeLuminance()` and is asserted, not eyeballed: 11 tests across both themes +covering body text on ground/surface (AA normal, 4.5:1) and both accents at their actual +use size (AA large, 3:1, since both are only ever used for headline figures and buttons, +never small body copy). Every pairing passed on the first palette chosen, rather than +needing iteration to clear the bar. + +**Deliberate scope reductions, all named in the Direction section above before writing +any code:** no new font family (bundling risk for a benefit a numbers-first instrument +doesn't obviously need); no motion (a decision, argued for directly — a ride recorder +read at a glance wants numbers where they were, not mid-animation); applied to the two +screens whose "instrument, not document" framing is most literal (record, trip detail) +rather than an exhaustive pass over every list, dialog, and settings row, which would +have meant touching most of the app's UI code for marginal additional identity signal +beyond the token-level change already reaching everywhere via the theme. + +**Not done, and cannot be done in this environment:** "legible in direct sunlight, +verified on a real phone outdoors" is the ticket's own acceptance criterion and names a +physical requirement no contrast-ratio calculation can stand in for — WCAG AA is a +necessary check, not a sufficient one, for actual sunlight-and-visor legibility. Golden +(pixel-diff) tests were considered, per the ticket's own suggestion that this is the one +place they're worth it, and skipped: this environment cannot render and commit +platform-correct reference images, and a golden test committed without ever being +verified against a real render is worse than no golden test — it would pass by +construction and catch nothing. `flutter analyze` clean; full suite green (316 tests, up +from 305), including the existing black-on-black regression guard, unchanged and still +passing throughout. diff --git a/lib/src/ui/components/stats.dart b/lib/src/ui/components/stats.dart index 257dd97..792d020 100644 --- a/lib/src/ui/components/stats.dart +++ b/lib/src/ui/components/stats.dart @@ -71,11 +71,17 @@ class BigStat extends StatelessWidget { } class StatRow extends StatelessWidget { - const StatRow({super.key, required this.label, required this.value}); + const StatRow({super.key, required this.label, required this.value, this.reference = false}); final String label; final String value; + /// V3-16: true for a figure you compare the live reading against (a max, an average) + /// rather than the live reading itself. Colours the value with the instrument-blue + /// tertiary instead of the default ink, so the same colour means the same kind of + /// number everywhere it appears. See the ticket's Direction section. + final bool reference; + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -89,7 +95,7 @@ class StatRow extends StatelessWidget { value, style: monoDigits.copyWith( fontWeight: FontWeight.w500, - color: colors.onSurface, + color: reference ? colors.tertiary : colors.onSurface, ), ), ], diff --git a/lib/src/ui/detail/trip_detail_screen.dart b/lib/src/ui/detail/trip_detail_screen.dart index 75a1d63..c6afe6d 100644 --- a/lib/src/ui/detail/trip_detail_screen.dart +++ b/lib/src/ui/detail/trip_detail_screen.dart @@ -354,10 +354,12 @@ class _Body extends StatelessWidget { StatRow( label: 'Max speed', value: formatSpeed(s.maxSpeedKmh, unit: units), + reference: true, ), StatRow( label: 'Avg moving speed', value: formatSpeed(s.avgMovingSpeedKmh, unit: units), + reference: true, ), StatRow( label: 'Elevation gain', diff --git a/lib/src/ui/record/record_screen.dart b/lib/src/ui/record/record_screen.dart index 0fc233d..932053d 100644 --- a/lib/src/ui/record/record_screen.dart +++ b/lib/src/ui/record/record_screen.dart @@ -298,6 +298,7 @@ class _RecordScreenState extends ConsumerState { ui.trip!.maxSpeedKmh, unit: units, ), + reference: true, ), StatRow( label: 'Points captured', diff --git a/lib/src/ui/theme.dart b/lib/src/ui/theme.dart index 85f0285..5ab9085 100644 --- a/lib/src/ui/theme.dart +++ b/lib/src/ui/theme.dart @@ -8,21 +8,31 @@ library; import 'package:flutter/material.dart'; -// Matches the launcher icon: near-black tarmac with a safety-orange accent. +// Matches the launcher icon: near-black tarmac with a safety-orange accent. V3-16 +// warmed the ground fractionally (asphalt, not a generic dark-mode blue-black) and added +// an instrument-blue tertiary reserved for reference readings -- see the ticket's +// Direction section for why. const _accent = Color(0xFFFF5722); -const _ground = Color(0xFF101418); -const _surface = Color(0xFF181D23); +const _ground = Color(0xFF120F0D); +const _surface = Color(0xFF1C1917); const _ink = Color(0xFFF2F5F8); -const _outline = Color(0xFF66727E); +const _outline = Color(0xFF6E7681); const _danger = Color(0xFFE53935); +/// Reference readings only (a max, an average) -- never the live figure, which stays +/// [_accent]. The same colour [RideMap] already uses for its slowest speed bucket, +/// reused rather than invented, so "instrument blue" means one thing everywhere. +const _instrumentBlue = Color(0xFF4FC3F7); + const ripprColors = ColorScheme.dark( primary: _accent, onPrimary: Color(0xFF100A06), secondary: _outline, + tertiary: _instrumentBlue, + onTertiary: Color(0xFF042433), surface: _surface, onSurface: _ink, - surfaceContainerHighest: Color(0xFF212832), + surfaceContainerHighest: Color(0xFF262220), onSurfaceVariant: _ink, outline: _outline, error: _danger, @@ -53,7 +63,7 @@ ThemeData ripprTheme() { foregroundColor: _ink, elevation: 0, ), - dividerTheme: const DividerThemeData(color: Color(0xFF2A323C), space: 1), + dividerTheme: const DividerThemeData(color: Color(0xFF322C28), space: 1), ); } @@ -64,10 +74,17 @@ const _mountedGround = Color(0xFFF5F3EF); const _mountedInk = Color(0xFF12100D); const _mountedAccent = Color(0xFFD84315); +// V3-16: the same instrument/reference split as the pocketed theme, darkened enough to +// hold WCAG AA against a near-white ground -- the mounted theme's own reason to exist +// (sunlight, a visor) is not touched by this, only extended. +const _mountedInstrumentBlue = Color(0xFF01579B); + const ripprMountedColors = ColorScheme.light( primary: _mountedAccent, onPrimary: Color(0xFFFFFFFF), secondary: Color(0xFF4A4034), + tertiary: _mountedInstrumentBlue, + onTertiary: Color(0xFFFFFFFF), surface: Color(0xFFFFFFFF), onSurface: _mountedInk, surfaceContainerHighest: Color(0xFFE7E2D9), @@ -107,6 +124,18 @@ ThemeData ripprMountedTheme() { ); } +/// WCAG 2.x's contrast ratio: `(L1 + 0.05) / (L2 + 0.05)`, `L1` the lighter of the two +/// relative luminances. AA requires 4.5:1 for normal text, 3:1 for large text (18pt, or +/// 14pt bold) -- see the tests in `theme_test.dart` for V3-16's acceptance criterion, +/// which asserts this directly rather than trusting a palette pick by eye. +double contrastRatio(Color a, Color b) { + final la = a.computeLuminance(); + final lb = b.computeLuminance(); + final lighter = la > lb ? la : lb; + final darker = la > lb ? lb : la; + return (lighter + 0.05) / (darker + 0.05); +} + /// Monospace for anything numeric. /// /// Digits that change every second must not make the layout jitter, which proportional diff --git a/test/theme_test.dart b/test/theme_test.dart new file mode 100644 index 0000000..35b136f --- /dev/null +++ b/test/theme_test.dart @@ -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), + ); + }); + }); +}