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

View File

@@ -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-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-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-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 ## Dependencies

View File

@@ -1,6 +1,40 @@
# V3-16 — Visual identity # 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 ## Goal
Move from "functional dark" to a look that is deliberately designed. 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 ## Out of scope
A new app icon. The Route mark is good and recently applied. 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.

View File

@@ -71,11 +71,17 @@ class BigStat extends StatelessWidget {
} }
class StatRow 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 label;
final String value; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
@@ -89,7 +95,7 @@ class StatRow extends StatelessWidget {
value, value,
style: monoDigits.copyWith( style: monoDigits.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: colors.onSurface, color: reference ? colors.tertiary : colors.onSurface,
), ),
), ),
], ],

View File

@@ -354,10 +354,12 @@ class _Body extends StatelessWidget {
StatRow( StatRow(
label: 'Max speed', label: 'Max speed',
value: formatSpeed(s.maxSpeedKmh, unit: units), value: formatSpeed(s.maxSpeedKmh, unit: units),
reference: true,
), ),
StatRow( StatRow(
label: 'Avg moving speed', label: 'Avg moving speed',
value: formatSpeed(s.avgMovingSpeedKmh, unit: units), value: formatSpeed(s.avgMovingSpeedKmh, unit: units),
reference: true,
), ),
StatRow( StatRow(
label: 'Elevation gain', label: 'Elevation gain',

View File

@@ -298,6 +298,7 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
ui.trip!.maxSpeedKmh, ui.trip!.maxSpeedKmh,
unit: units, unit: units,
), ),
reference: true,
), ),
StatRow( StatRow(
label: 'Points captured', label: 'Points captured',

View File

@@ -8,21 +8,31 @@ library;
import 'package:flutter/material.dart'; 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 _accent = Color(0xFFFF5722);
const _ground = Color(0xFF101418); const _ground = Color(0xFF120F0D);
const _surface = Color(0xFF181D23); const _surface = Color(0xFF1C1917);
const _ink = Color(0xFFF2F5F8); const _ink = Color(0xFFF2F5F8);
const _outline = Color(0xFF66727E); const _outline = Color(0xFF6E7681);
const _danger = Color(0xFFE53935); 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( const ripprColors = ColorScheme.dark(
primary: _accent, primary: _accent,
onPrimary: Color(0xFF100A06), onPrimary: Color(0xFF100A06),
secondary: _outline, secondary: _outline,
tertiary: _instrumentBlue,
onTertiary: Color(0xFF042433),
surface: _surface, surface: _surface,
onSurface: _ink, onSurface: _ink,
surfaceContainerHighest: Color(0xFF212832), surfaceContainerHighest: Color(0xFF262220),
onSurfaceVariant: _ink, onSurfaceVariant: _ink,
outline: _outline, outline: _outline,
error: _danger, error: _danger,
@@ -53,7 +63,7 @@ ThemeData ripprTheme() {
foregroundColor: _ink, foregroundColor: _ink,
elevation: 0, 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 _mountedInk = Color(0xFF12100D);
const _mountedAccent = Color(0xFFD84315); 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( const ripprMountedColors = ColorScheme.light(
primary: _mountedAccent, primary: _mountedAccent,
onPrimary: Color(0xFFFFFFFF), onPrimary: Color(0xFFFFFFFF),
secondary: Color(0xFF4A4034), secondary: Color(0xFF4A4034),
tertiary: _mountedInstrumentBlue,
onTertiary: Color(0xFFFFFFFF),
surface: Color(0xFFFFFFFF), surface: Color(0xFFFFFFFF),
onSurface: _mountedInk, onSurface: _mountedInk,
surfaceContainerHighest: Color(0xFFE7E2D9), 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. /// Monospace for anything numeric.
/// ///
/// Digits that change every second must not make the layout jitter, which proportional /// Digits that change every second must not make the layout jitter, which proportional

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),
);
});
});
}