From 21072633b5b5a2be2ac5831ac7ac5234b06f6958 Mon Sep 17 00:00:00 2001 From: vjrj Date: Thu, 9 Jul 2026 12:50:26 +0200 Subject: [PATCH] fix(ui): darken seedMuted and app-bar green to meet WCAG AA contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seedMuted (hints, card subtitles, search placeholder) was #8A9880 — 2.62:1 on the canvas and 3.05:1 on white, failing AA for the 13px text it colours. Darkened to #617057: 5.30:1 on white, 4.56:1 on canvas, 4.88:1 on the field fill, same muted green-grey hue. The new contrast test also caught the app-bar green: white titles on #3E8C38 were 4.19:1, so it is nudged to #3A8434 (4.64:1) — a barely visible change. test/ui/theme_contrast_test.dart pins the WCAG relative-luminance math and asserts every text/background pair the palette actually uses. --- .../lib/ui/inventory_list_screen.dart | 3 +- apps/app_seeds/lib/ui/theme.dart | 9 +++- .../test/ui/theme_contrast_test.dart | 52 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 apps/app_seeds/test/ui/theme_contrast_test.dart diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index f782e23..a3442ed 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -233,8 +233,7 @@ class _VarietyTile extends StatelessWidget { ), trailing: IconButton( icon: const Icon(Icons.edit_outlined), - // Action colour (≥3:1 on the canvas) so the control stays legible; - // seedMuted here fell to 2.62:1. + // Action colour: this is a tap target, not secondary text. color: seedGreen, tooltip: context.t.common.edit, onPressed: open, diff --git a/apps/app_seeds/lib/ui/theme.dart b/apps/app_seeds/lib/ui/theme.dart index 1af7383..392e37a 100644 --- a/apps/app_seeds/lib/ui/theme.dart +++ b/apps/app_seeds/lib/ui/theme.dart @@ -6,7 +6,9 @@ import 'package:flutter/material.dart'; /// headers) and a soft green container for selected/tonal surfaces. const seedGreen = Color(0xFF2F7D34); // primary (actions, links, headers) const seedGreenDark = Color(0xFF22521E); // status bar / darker green -const seedAppBar = Color(0xFF3E8C38); // app-bar green +// App-bar green. Nudged down from the mockups' #3E8C38 so its white title +// meets WCAG AA (4.5:1) — see test/ui/theme_contrast_test.dart. +const seedAppBar = Color(0xFF3A8434); const seedCanvas = Color(0xFFE7F1E0); // surface / scaffold const seedPrimaryContainer = Color(0xFFD3E8C8); // tonal green container const seedOnPrimaryContainer = Color(0xFF2F6D2A); @@ -18,7 +20,10 @@ const seedDivider = Color(0xFFE4E9DD); const seedTitle = Color(0xFF1B2416); // heading text on canvas const seedOnSurface = Color(0xFF1B2416); const seedOnSurfaceVariant = Color(0xFF5C6B52); // scientific names, secondary -const seedMuted = Color(0xFF8A9880); // hints, list edit icon +// Hints and secondary text/icons. Darkened from the mockups' #8A9880 (which +// fell below WCAG AA) to keep ≥4.5:1 on white, the canvas and the field fill — +// see test/ui/theme_contrast_test.dart. +const seedMuted = Color(0xFF617057); const seedRating = Color(0xFFE8A200); // stars ThemeData buildTaneTheme() { diff --git a/apps/app_seeds/test/ui/theme_contrast_test.dart b/apps/app_seeds/test/ui/theme_contrast_test.dart new file mode 100644 index 0000000..1b514ac --- /dev/null +++ b/apps/app_seeds/test/ui/theme_contrast_test.dart @@ -0,0 +1,52 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart' show Colors; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/ui/theme.dart'; + +/// WCAG 2.x relative luminance of a color (sRGB). +double _luminance(Color c) { + double channel(double v) => + v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4).toDouble(); + return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b); +} + +/// WCAG contrast ratio between two colors (order-independent, 1..21). +double _contrast(Color a, Color b) { + final la = _luminance(a); + final lb = _luminance(b); + final lighter = max(la, lb); + final darker = min(la, lb); + return (lighter + 0.05) / (darker + 0.05); +} + +void main() { + void expectAa(Color foreground, Color background, String label) { + expect( + _contrast(foreground, background), + greaterThanOrEqualTo(4.5), + reason: '$label must meet WCAG AA (4.5:1) for normal text', + ); + } + + group('palette meets WCAG AA (4.5:1) for text', () { + test('seedMuted (hints, subtitles) on every background it sits on', () { + // White: search-field fill and home cards. Canvas/field: M3 surfaces. + expectAa(seedMuted, Colors.white, 'seedMuted on white'); + expectAa(seedMuted, seedCanvas, 'seedMuted on canvas'); + expectAa(seedMuted, seedField, 'seedMuted on field fill'); + }); + + test('body and secondary text on the canvas', () { + expectAa(seedOnSurface, seedCanvas, 'onSurface on canvas'); + expectAa(seedTitle, seedCanvas, 'titles on canvas'); + expectAa(seedOnSurfaceVariant, seedCanvas, 'onSurfaceVariant on canvas'); + }); + + test('white text on the green action colours', () { + expectAa(Colors.white, seedGreen, 'white on primary green'); + expectAa(Colors.white, seedAppBar, 'white on app-bar green'); + }); + }); +}