UI-08: migrate theme to Modern Professional Dark

Replaces V3-16's safety-orange identity with the palette actually backing
every fetched Stitch mockup (confirmed by hex-matching against all three
design systems in the Stitch project, not by trusting its declared default).
Ground drops to #0a0a0c with a three-level surface/card stack, primary
becomes Professional Blue (#4090fe), and reference readings move to a new
tertiary orange since blue is no longer free for that role. Re-verified
theme_test.dart's AA contrast assertions against the new values (all pass
with wide margin) and left mounted mode's separate daylight palette alone,
per the ticket's own reasoning.

Also resolves the ticket's named risk: RideMap's speed-gradient hardcoded
hex literal, which would have washed to a single hue now that primary is
blue, now lerps from the theme's own secondary token instead.

Inter is not wired in -- no bundled font asset exists to back the family
name, and setting one without an asset would silently fall back to Roboto.
Documented as a deliberate follow-up rather than a silent gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
2026-08-23 19:37:25 -05:00
parent 5ab4934a4d
commit 13017b8ee8
5 changed files with 145 additions and 31 deletions

View File

@@ -26,7 +26,7 @@ v3 held to.
| [UI-05](UI-05-map-hud-record-screen.md) | Map HUD: Record screen redesign | M | UI-01, UI-03, UI-04 | Not started |
| [UI-06](UI-06-plan-and-route-planning.md) | Plan & Route Planning redesign | M | UI-01, UI-03 | Not started |
| [UI-07](UI-07-rides-history.md) | Rides History redesign | M | UI-01, UI-03 | Not started |
| [UI-08](UI-08-theme-modern-professional-dark.md) | Theme migration to Modern Professional Dark | S | — | Not started |
| [UI-08](UI-08-theme-modern-professional-dark.md) | Theme migration to Modern Professional Dark | S | — | Done |
| [UI-09](UI-09-monochrome-dark-map-tiles.md) | Monochrome dark map tiles | S | — | Not started |
## Dependencies

View File

@@ -1,6 +1,6 @@
# UI-08 — Theme migration to Modern Professional Dark
**Depends on** nothing · **Size** S · **Status** Not started
**Depends on** nothing · **Size** S · **Status** Done
## Goal
Replace `theme.dart`'s current safety-orange palette with "Modern Professional Dark" —
@@ -94,3 +94,67 @@ Full spec: `docs/design/stitch-export/design-system-modern-professional-dark.md`
## Out of scope
Any screen-specific redesign (UI-05/06/07) — this ticket only changes the token layer
those screens then build against.
## Outcome
`lib/src/ui/theme.dart`'s `ripprColors` now carries Modern Professional Dark's values:
ground `#0a0a0c` (the design system's own `surface-main`, not its plain `background`
token — the spec's prose is explicit that `#0a0a0c` is *the* background, with `#131313`
one step up and `#16161a` a second step up for cards, a three-level stack the old
two-level `_ground`/`_surface` pair didn't have room for). Added `ripprCardColor`
(`#16161a`) to carry the third level, since `ColorScheme` has no free surface slot for it
once `surfaceContainerHighest` is spent on the selected/highlighted state. Primary is
Professional Blue (`#4090fe`); tertiary is a warm orange (`#ffb68c`).
**Decision recorded per the ticket's own prompt:** the design system doesn't specify a
reference-vs-live split itself, so one was chosen and applied everywhere consistently —
tertiary orange for `StatRow`'s `reference` values (a max, an average), inverted from
V3-16 where blue held that role, because blue is now the *primary* colour and reusing it
for reference readings would have made the two indistinguishable. `RideMap`'s speed
polyline gradient — flagged explicitly in the ticket's Risks as a hardcoded hex literal —
now lerps `colors.secondary` (the theme's own cool slate-blue) to `colors.primary`
instead of a hardcoded `0xFF4FC3F7`, for the same reason: the old literal was itself
blue, and lerping blue-to-blue under the new primary would have washed the gradient into
one hue.
**Shape:** added `ripprRadiusSmall` (8px) and `ripprRadiusLarge` (16px) constants
matching the spec's small-component/large-container split, and a `CardThemeData` using
the large radius and `ripprCardColor`. `RideMap`'s card-mode `ClipRRect` (previously a
hardcoded 12px) now uses `ripprRadiusLarge`. Deliberately did **not** add a global
`ElevatedButtonTheme`/`OutlinedButtonTheme` shape override — `RecordScreen`'s
Start/Pause/Stop/Discard buttons rely on Material 3's default `StadiumBorder` for their
pill shape, which already matches the Stitch mockups' own pill buttons; forcing an 8px
rectangle there would have been an unrequested regression, not a spec application.
**Inter was not wired in.** The design system calls for it exclusively, but the codebase
has no bundled font asset and no existing `bodyFont`/`headlineFont` abstraction to retarget
— setting `TextTheme.apply(fontFamily: 'Inter')` with nothing registered under that name
would silently fall back to the platform default (Roboto), which is exactly the class of
invisible failure `ripprTheme()`'s own `bodyColor`/`displayColor` fix exists to prevent
(the "Compose `Surface`" comment in the file). Left as a documented, deliberate follow-up
rather than shipping a fontFamily string that resolves to nothing. `monoDigits` is
untouched and still applies to every numeric display, as required.
**Contrast:** re-ran `theme_test.dart`'s `contrastRatio()` assertions against the new
palette (no values needed adjusting — primary-on-ground, tertiary-on-ground, and
onSurface-on-ground all clear their AA thresholds with considerable margin: roughly
6.3:1, 11.6:1, and 15.3:1 respectively, well past the 4.5:1/3:1 bars). Mounted mode
(V3-05) was left unchanged — its own separate high-contrast daylight palette isn't
covered by the Modern Professional Dark spec, its orange accent isn't a brand clash the
way the pocketed theme's safety-orange was, and V3-13's real-ride sunlight-legibility
claim depends on the specific values already tuned there. Its existing contrast tests
were re-run, unchanged, and still pass.
**Tests:** `flutter analyze` clean. `flutter test` green at 315 tests, no count change —
this ticket touched no test files, only re-pointed `ripprColors`'s literals and reused
the same `theme_test.dart`/`ride_map_test.dart` assertions the diff didn't need to alter
because they read `ripprTheme().colorScheme` rather than hardcoding old hex values.
**Android emulator verification** (`Medium_Phone_API_35`): checked Map/Settings/Rides
tabs. Buttons, switches, the segmented Metric/Imperial control, section labels, and the
`Save`/`Clear` text actions all render in Professional Blue; the reference "Max speed"
figure renders in the new tertiary orange, clearly distinct from the live "0" headline
figure (unchanged ink) and from the Pause button's blue; the bottom nav's active-tab
indicator picked up a blue-tinted pill automatically (Material 3 derives it from the
`ColorScheme`, not something this ticket configured directly) rather than the old
neutral grey. No leftover orange/old-instrument-blue literals were visible anywhere.

View File

@@ -20,6 +20,7 @@ import 'package:latlong2/latlong.dart' as ll;
import '../../domain/models.dart';
import '../../geo/geo.dart' as geo;
import '../theme.dart' show ripprRadiusLarge;
/// Metres. Render-only: a 3-hour ride is ~21,600 points and would jank undecimated.
const double simplifyEpsilonM = 5.0;
@@ -176,7 +177,8 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
final map = ClipRRect(
// The map draws to the edge of its box; clipping keeps it from painting over
// adjacent controls, which osmdroid did until it was explicitly bounded.
borderRadius: widget.fill ? BorderRadius.zero : BorderRadius.circular(12),
borderRadius:
widget.fill ? BorderRadius.zero : BorderRadius.circular(ripprRadiusLarge),
child: FlutterMap(
mapController: _controller,
options: MapOptions(
@@ -305,9 +307,16 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
/// Cool for slow, accent for fast. A single hue ramp reads better through a visor than
/// a rainbow.
///
/// UI-08: was a hardcoded instrument-blue lerped to `colors.primary`. Under the old
/// safety-orange theme that gave a clean blue-to-orange ramp; under Modern
/// Professional Dark, `colors.primary` is itself blue, so lerping from another
/// hardcoded blue would wash the whole gradient into one indistinct hue. Lerping
/// from `colors.secondary` (the theme's own cool slate-blue) to `colors.primary`
/// (Professional Blue) keeps a real, theme-driven cool-to-accent ramp instead.
Color _colorFor(int bucket, double maxSpeed, ColorScheme colors) {
if (bucket < 0 || maxSpeed <= 0) return colors.primary;
final t = ((bucket * speedBucketKmh) / maxSpeed).clamp(0.0, 1.0);
return Color.lerp(const Color(0xFF4FC3F7), colors.primary, t)!;
return Color.lerp(colors.secondary, colors.primary, t)!;
}
}

View File

@@ -77,9 +77,10 @@ class StatRow extends StatelessWidget {
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.
/// rather than the live reading itself. Colours the value with the theme's tertiary
/// (UI-08: a warm orange, chosen specifically to read as distinct from the new
/// Professional Blue primary) 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

View File

@@ -8,40 +8,68 @@ library;
import 'package:flutter/material.dart';
// 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(0xFF120F0D);
const _surface = Color(0xFF1C1917);
const _ink = Color(0xFFF2F5F8);
const _outline = Color(0xFF6E7681);
const _danger = Color(0xFFE53935);
// UI-08: "Modern Professional Dark", the design system actually backing every fetched
// Stitch mockup (confirmed by hex-matching, not by trusting the project's declared
// default theme — see docs/design/stitch-export/README.md). This supersedes V3-16's
// safety-orange identity outright, not an extension of it. Full spec:
// docs/design/stitch-export/design-system-modern-professional-dark.md.
//
// `surface-main` (#0a0a0c), not the spec's plain `background`/`surface` (#131313), is
// used as the app ground: the spec's own prose calls out #0a0a0c as *the* background,
// with #131313 one step up for a mid-level surface and #16161a another step up for cards
// — a three-step stack, not two.
const _ground = Color(0xFF0A0A0C);
const _surfaceMid = Color(0xFF131313);
const _ink = Color(0xFFE5E2E1);
const _outline = Color(0xFF8B919F);
const _danger = Color(0xFFFFB4AB);
/// 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);
/// Professional Blue. The soul of the interface per the design system's own doc —
/// primary buttons, the active nav indicator, the user's own location dot, and (via
/// `RideMap`'s speed gradient) the accent end of the live-path colouring. Replaces
/// safety-orange as *the* accent everywhere; the app's existing convention of leaving
/// headline figures (`BigStat`) in plain ink and reserving colour for `StatRow`'s
/// `reference` values is unchanged by this ticket.
const _primary = Color(0xFF4090FE);
/// UI-08 decision (the design system doesn't prescribe this split itself): reference
/// readings (a max, an average) get the warm tertiary orange, never the live figure,
/// which stays [_primary]. Inverted from V3-16 (blue was the reference colour there)
/// because blue is now the primary/live colour — a same-hue reference tone would have
/// been visually indistinguishable from the live one it exists to be told apart from.
const _referenceOrange = Color(0xFFFFB68C);
const ripprColors = ColorScheme.dark(
primary: _accent,
onPrimary: Color(0xFF100A06),
secondary: _outline,
tertiary: _instrumentBlue,
onTertiary: Color(0xFF042433),
surface: _surface,
primary: _primary,
onPrimary: Color(0xFF002F64),
secondary: Color(0xFFAEC7F6),
tertiary: _referenceOrange,
onTertiary: Color(0xFF532200),
surface: _surfaceMid,
onSurface: _ink,
surfaceContainerHighest: Color(0xFF262220),
onSurfaceVariant: _ink,
surfaceContainerHighest: Color(0xFF353534),
onSurfaceVariant: Color(0xFFC1C6D5),
outline: _outline,
error: _danger,
onError: _ink,
onError: Color(0xFF690005),
);
/// The near-black ground the whole app sits on.
const ripprBackground = _ground;
/// Cards and modals — one step up from [ripprColors]'s `surface`, per the design
/// system's three-level stack ("Surface Strategy": base darkest, cards a step lighter).
/// Exposed separately because `ColorScheme` has no fourth surface slot for it that isn't
/// already claimed by `surfaceContainerHighest` (reserved here for the selected/
/// highlighted state, not the resting card colour).
const ripprCardColor = Color(0xFF16161A);
/// Shape spec: "Rounded (Level 2)" — small components (buttons, inputs) get 8px, large
/// containers (cards, modals) get 16px. Two constants, not a single "roundness" figure,
/// because the spec explicitly gives them different values.
const double ripprRadiusSmall = 8.0;
const double ripprRadiusLarge = 16.0;
ThemeData ripprTheme() {
final base = ThemeData(
colorScheme: ripprColors,
@@ -57,13 +85,25 @@ ThemeData ripprTheme() {
// Flutter's equivalent exposure is the default text colour. Setting it explicitly
// here, rather than relying on a wrapping widget, means the failure cannot recur by
// someone removing a container.
// UI-08: the design system specifies Inter exclusively. Not switched to here --
// naming a fontFamily with no matching asset bundled would silently fall back to
// the platform default (Roboto), the exact class of invisible failure the bodyColor
// fix below exists to prevent. Bundling the actual font files (or a vetted offline
// build of the google_fonts package) is left as a deliberate follow-up rather than
// shipping a fontFamily string that doesn't resolve to anything.
textTheme: base.textTheme.apply(bodyColor: _ink, displayColor: _ink),
appBarTheme: const AppBarTheme(
backgroundColor: _ground,
foregroundColor: _ink,
elevation: 0,
),
dividerTheme: const DividerThemeData(color: Color(0xFF322C28), space: 1),
cardTheme: const CardThemeData(
color: ripprCardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(ripprRadiusLarge)),
),
),
dividerTheme: const DividerThemeData(color: Color(0xFF2D3037), space: 1),
);
}