diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6bb28..29a07a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed + +- **A selected tab underlined itself in the colour of the rule it sits on, and the first fix put it one brand shade off.** The indicator used `border-color-border`, the same token as the tab list's own bottom rule, so a selection marked itself with a thicker length of the very line under it and read as a grey smudge. Replacing it with a bare `selected:border-primary` fixed the smudge and introduced a subtler wrong: there is no `border-color-primary` alias, so the alias layer passes the token through untouched and wind's border parser defaults the missing shade to 500, while every other brand surface resolves `bg-primary` to primary-600 in light mode. An active tab therefore underlined in primary-500 beside a primary-600 button, and the bare token carried no `dark:` half at all, contrary to this project's own widget rules. Now `selected:border-primary-600 dark:selected:border-primary-500`, asserted against the colour wind itself resolves for those shades in both modes rather than against a hardcoded hex. (`lib/src/ui/components/tabs/tabs.recipe.dart`, `test/ui/components/tabs/tabs_test.dart`) + ## [0.0.1-alpha.20] - 2026-08-17 ### Fixed diff --git a/lib/src/ui/components/tabs/tabs.recipe.dart b/lib/src/ui/components/tabs/tabs.recipe.dart index 5ca021d..c38523e 100644 --- a/lib/src/ui/components/tabs/tabs.recipe.dart +++ b/lib/src/ui/components/tabs/tabs.recipe.dart @@ -17,8 +17,12 @@ Map tabsRecipe({ const recipe = WindSlotRecipe( slots: { 'list': 'flex flex-row border-b border-color-border', + // The selected underline is the BRAND colour, not `border-color-border`: + // that is the same token as the `list` rule the tabs sit on, so an active + // tab used to mark itself with a thicker length of the very line it was + // sitting on. It read as a grey smudge rather than a selection. 'tab': - 'px-4 py-2 text-sm font-medium text-fg-muted cursor-pointer selected:text-fg selected:border-b-2 selected:border-color-border', + 'px-4 py-2 text-sm font-medium text-fg-muted cursor-pointer selected:text-fg selected:border-b-2 selected:border-primary-600 dark:selected:border-primary-500', 'panel': 'pt-4', }, ); diff --git a/test/ui/components/tabs/tabs_test.dart b/test/ui/components/tabs/tabs_test.dart index 8d66d43..0ca0dd5 100644 --- a/test/ui/components/tabs/tabs_test.dart +++ b/test/ui/components/tabs/tabs_test.dart @@ -73,6 +73,96 @@ void main() { expect(find.text('Panel 2'), findsNothing); }); + testWidgets( + 'the selected tab does not underline itself in the rule ' + 'colour', (tester) async { + // The indicator used `border-color-border`, the same token as the rule + // the tab list sits on, so a selected tab marked itself with a thicker + // length of the very line under it and read as a grey smudge. Asserted on + // the rendered colours rather than the className: the className was + // perfectly valid, it just resolved to the background it was drawn over. + await tester.pumpWidget( + wrap( + MSTabs( + tabs: const ['Tab 1', 'Tab 2'], + selectedIndex: 0, + onChanged: (_) {}, + panelBuilder: (i) => Text('Panel $i'), + ), + ), + ); + await tester.pumpAndSettle(); + + final Set bottomBorders = tester + .widgetList(find.byType(DecoratedBox)) + .map((box) => box.decoration) + .whereType() + .where((decoration) => (decoration.border?.bottom.width ?? 0) > 0) + .map((decoration) => decoration.border!.bottom.color) + .toSet(); + + expect( + bottomBorders.length, + greaterThan(1), + reason: 'the active indicator and the list rule cannot be the same ' + 'colour, or the selection is invisible', + ); + + // And it is the BRAND, not merely something else. The assertion above + // stays green if the token becomes `border-red-500`, so on its own it pins + // the indicator away from the rule without pinning it to anything. A bare + // `border-primary` is what made this necessary: there is no + // `border-color-primary` alias, so the alias layer passes it through and + // wind's border parser defaults the missing shade to 500, while every + // other brand surface resolves `bg-primary` to primary-600 in light mode. + // One shade off, next to a button that is not. + expect( + bottomBorders, + contains(_brandShade(600)), + reason: 'the indicator must be the same brand shade as bg-primary', + ); + }); + + testWidgets('the indicator tracks the brand in dark mode too', ( + tester, + ) async { + // `.claude/rules/widgets.md`: always pair light and dark. The bare token + // this replaced had no `dark:` half at all, so dark mode inherited the + // light shade. + // `syncWithSystem: false` so the brightness set here survives: a syncing + // theme is forced to the test platform's brightness on startup, which is + // light, and the whole case would then assert light twice. + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData( + brightness: Brightness.dark, + syncWithSystem: false, + ), + child: Scaffold( + body: MSTabs( + tabs: const ['Tab 1', 'Tab 2'], + selectedIndex: 0, + onChanged: (_) {}, + panelBuilder: (i) => Text('Panel $i'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final Set bottomBorders = tester + .widgetList(find.byType(DecoratedBox)) + .map((box) => box.decoration) + .whereType() + .where((decoration) => (decoration.border?.bottom.width ?? 0) > 0) + .map((decoration) => decoration.border!.bottom.color) + .toSet(); + + expect(bottomBorders, contains(_brandShade(500))); + }); + testWidgets('calls onChanged when a tab is tapped', (tester) async { int? changed; await tester.pumpWidget( @@ -112,3 +202,16 @@ void main() { }); }); } + +/// The brand colour wind resolves `primary-` to, read from wind itself +/// rather than hardcoded. +/// +/// Hardcoding a hex here would pin the test to Tailwind's palette rather than to +/// the theme, and the first guess (Material's `Colors.blue.shade600`) was a +/// different colour entirely: wind's default primary is Tailwind blue, so the +/// indicator renders #2563EB and Material's 600 is #1E88E5. +Color _brandShade(int shade) { + final WindThemeData theme = WindThemeData(); + + return theme.colors['primary']![shade]!; +}