polish(ux): selected filter chips fill solid for clear contrast

The selected state used a pale tonal container (seedPrimaryContainer)
that sat almost on top of the pale canvas, so 'on' barely read. Selected
chips now fill solid — seedGreen for the plain attribute/market chips and
the swatch's own ink for family/form chips — with white label, icon and
checkmark. Idle chips stay light with dark ink, so the on/off jump is
unmistakable. White-on-ink AA is locked by a new contrast test.
This commit is contained in:
vjrj 2026-07-11 07:48:27 +02:00
parent 805f1dc535
commit 4a1e72bc7e
5 changed files with 53 additions and 12 deletions

View file

@ -164,9 +164,9 @@ class _MonthStripState extends State<_MonthStrip> {
), ),
selected: selected, selected: selected,
onSelected: (_) => widget.onSelect(m), onSelected: (_) => widget.onSelect(m),
selectedColor: seedPrimaryContainer, selectedColor: seedGreen,
labelStyle: TextStyle( labelStyle: TextStyle(
color: selected ? seedOnPrimaryContainer : seedMuted, color: selected ? Colors.white : seedMuted,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400, fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
), ),
); );

View file

@ -308,7 +308,7 @@ class _FilterBar extends StatelessWidget {
avatar: Icon( avatar: Icon(
Icons.calendar_month, Icons.calendar_month,
size: 18, size: 18,
color: state.sowThisMonthOnly ? null : seedGreen, color: state.sowThisMonthOnly ? Colors.white : seedGreen,
), ),
label: Text(t.calendar.filterChip), label: Text(t.calendar.filterChip),
selected: state.sowThisMonthOnly, selected: state.sowThisMonthOnly,
@ -320,7 +320,7 @@ class _FilterBar extends StatelessWidget {
avatar: Icon( avatar: Icon(
Icons.volunteer_activism_outlined, Icons.volunteer_activism_outlined,
size: 18, size: 18,
color: state.sharingOnly ? null : seedGreen, color: state.sharingOnly ? Colors.white : seedGreen,
), ),
label: Text(t.share.filterChip), label: Text(t.share.filterChip),
selected: state.sharingOnly, selected: state.sharingOnly,
@ -332,7 +332,7 @@ class _FilterBar extends StatelessWidget {
avatar: Icon( avatar: Icon(
Icons.eco, Icons.eco,
size: 18, size: 18,
color: state.organicOnly ? null : seedGreen, color: state.organicOnly ? Colors.white : seedGreen,
), ),
label: Text(t.editVariety.organic), label: Text(t.editVariety.organic),
selected: state.organicOnly, selected: state.organicOnly,
@ -344,7 +344,7 @@ class _FilterBar extends StatelessWidget {
avatar: Icon( avatar: Icon(
Icons.autorenew, Icons.autorenew,
size: 18, size: 18,
color: state.needsReproductionOnly ? null : seedGreen, color: state.needsReproductionOnly ? Colors.white : seedGreen,
), ),
label: Text(t.inventory.needsReproductionFilter), label: Text(t.inventory.needsReproductionFilter),
selected: state.needsReproductionOnly, selected: state.needsReproductionOnly,
@ -446,16 +446,23 @@ class _SwatchFilterChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Idle: a soft same-hue fill with dark ink. Selected: the ink becomes the
// fill and the content turns white, so the chip clearly reads as "on" while
// keeping its family/form hue. White on every ink meets WCAG AA see
// test/ui/category_palette_contrast_test.dart.
final ink = selected ? Colors.white : swatch.ink;
return FilterChip( return FilterChip(
label: Text(label), label: Text(label),
avatar: icon == null ? null : Icon(icon, size: 18, color: swatch.ink), avatar: icon == null ? null : Icon(icon, size: 18, color: ink),
selected: selected, selected: selected,
onSelected: (_) => onSelected(), onSelected: (_) => onSelected(),
backgroundColor: swatch.fill, backgroundColor: swatch.fill,
selectedColor: swatch.ink.withValues(alpha: 0.24), selectedColor: swatch.ink,
checkmarkColor: swatch.ink, checkmarkColor: Colors.white,
side: BorderSide(color: swatch.ink.withValues(alpha: 0.35)), side: BorderSide(
labelStyle: TextStyle(color: swatch.ink, fontWeight: FontWeight.w500), color: swatch.ink.withValues(alpha: selected ? 0 : 0.35),
),
labelStyle: TextStyle(color: ink, fontWeight: FontWeight.w500),
); );
} }
} }

View file

@ -493,7 +493,7 @@ class _MarketFilterBar extends StatelessWidget {
avatar: Icon( avatar: Icon(
Icons.eco, Icons.eco,
size: 18, size: 18,
color: state.organicOnly ? null : seedGreen, color: state.organicOnly ? Colors.white : seedGreen,
), ),
label: Text(t.editVariety.organic), label: Text(t.editVariety.organic),
selected: state.organicOnly, selected: state.organicOnly,

View file

@ -105,5 +105,24 @@ ThemeData buildTaneTheme() {
thickness: 1, thickness: 1,
color: seedDivider, color: seedDivider,
), ),
// Filter/choice chips: idle chips stay light with dark ink, but a *selected*
// chip fills solid green with white ink so "on" reads unmistakably against
// the pale canvas (the soft tonal default sat too close to it). White on
// seedGreen meets WCAG AA see test/ui/theme_contrast_test.dart. Chips that
// carry their own hue (family/form swatches) override this per-swatch.
chipTheme: const ChipThemeData(
selectedColor: seedGreen,
checkmarkColor: Colors.white,
labelStyle: TextStyle(
color: seedOnSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
secondaryLabelStyle: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
); );
} }

View file

@ -35,4 +35,19 @@ void main() {
reason: '$type ink on fill'); reason: '$type ink on fill');
} }
}); });
// A *selected* swatch chip fills solid with its ink and draws white content
// (label, icon, checkmark). That pairing must clear AA too.
test('white content meets AA on every selected (ink-filled) swatch', () {
for (var i = 0; i < 60; i++) {
final s = categorySwatch('family-$i');
expect(contrast(Colors.white, s.ink), greaterThanOrEqualTo(4.5),
reason: 'white on category-$i ink');
}
for (final type in LotType.values) {
final s = lotTypeSwatch(type);
expect(contrast(Colors.white, s.ink), greaterThanOrEqualTo(4.5),
reason: 'white on $type ink');
}
});
} }