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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -298,6 +298,7 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
ui.trip!.maxSpeedKmh,
|
||||
unit: units,
|
||||
),
|
||||
reference: true,
|
||||
),
|
||||
StatRow(
|
||||
label: 'Points captured',
|
||||
|
||||
@@ -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
|
||||
|
||||
82
test/theme_test.dart
Normal file
82
test/theme_test.dart
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user