From 13017b8ee835ffc169e9e9664c1307c108a04fc4 Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Sun, 23 Aug 2026 19:37:25 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr --- docs/ui-redesign/README.md | 2 +- .../UI-08-theme-modern-professional-dark.md | 66 +++++++++++++- lib/src/ui/components/ride_map.dart | 13 ++- lib/src/ui/components/stats.dart | 7 +- lib/src/ui/theme.dart | 88 ++++++++++++++----- 5 files changed, 145 insertions(+), 31 deletions(-) diff --git a/docs/ui-redesign/README.md b/docs/ui-redesign/README.md index ed3ea4d..9d3f6ed 100644 --- a/docs/ui-redesign/README.md +++ b/docs/ui-redesign/README.md @@ -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 diff --git a/docs/ui-redesign/UI-08-theme-modern-professional-dark.md b/docs/ui-redesign/UI-08-theme-modern-professional-dark.md index 0d18d49..407277a 100644 --- a/docs/ui-redesign/UI-08-theme-modern-professional-dark.md +++ b/docs/ui-redesign/UI-08-theme-modern-professional-dark.md @@ -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. diff --git a/lib/src/ui/components/ride_map.dart b/lib/src/ui/components/ride_map.dart index 1b091fc..9f473e9 100644 --- a/lib/src/ui/components/ride_map.dart +++ b/lib/src/ui/components/ride_map.dart @@ -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 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 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)!; } } diff --git a/lib/src/ui/components/stats.dart b/lib/src/ui/components/stats.dart index 792d020..bb2b7f2 100644 --- a/lib/src/ui/components/stats.dart +++ b/lib/src/ui/components/stats.dart @@ -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 diff --git a/lib/src/ui/theme.dart b/lib/src/ui/theme.dart index 5ab9085..735dbc8 100644 --- a/lib/src/ui/theme.dart +++ b/lib/src/ui/theme.dart @@ -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), ); }