From 20eb48cffaa35d107edfb597f0d8b005a171b1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 21 Aug 2026 00:19:20 +0300 Subject: [PATCH 1/2] fix(tabs): mark the active tab in the brand colour, not the rule colour The selected indicator was `selected:border-b-2 selected:border-color-border`, and `border-color-border` is the same token the `list` slot draws its own rule with. A selected tab therefore marked itself with a thicker length of the very line it was sitting on: on a phone it read as a grey smudge under one label rather than a selection. `selected:border-primary` resolves through the consumer's brand colour, so the indicator is the accent every other selected surface in an app already uses. The test asserts the two rendered border COLOURS differ rather than the className, because the className was never invalid: it resolved to the background it was drawn over. Verified non-vacuous by restoring the old token, which collapses the set to a single colour. --- lib/src/ui/components/tabs/tabs.recipe.dart | 6 +++- test/ui/components/tabs/tabs_test.dart | 36 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/src/ui/components/tabs/tabs.recipe.dart b/lib/src/ui/components/tabs/tabs.recipe.dart index 5ca021d..69aba13 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', 'panel': 'pt-4', }, ); diff --git a/test/ui/components/tabs/tabs_test.dart b/test/ui/components/tabs/tabs_test.dart index 8d66d43..3b4ecc3 100644 --- a/test/ui/components/tabs/tabs_test.dart +++ b/test/ui/components/tabs/tabs_test.dart @@ -73,6 +73,42 @@ 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', + ); + }); + testWidgets('calls onChanged when a tab is tapped', (tester) async { int? changed; await tester.pumpWidget( From 774780478a61784ea6dfe6968826ee292b28321f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Fri, 21 Aug 2026 22:09:26 +0300 Subject: [PATCH 2/2] fix(tabs): track the brand exactly, in both modes The review is right on all three points and I verified each before changing anything. `border-primary` is a bare palette utility, not a semantic role. There is no `border-color-primary` key in the alias map (only `border-color-border` and `border-color-border-subtle`), so the alias layer passes the token through untouched, and wind's border parser defaults a missing shade to 500 (`border_parser.dart:203`). Meanwhile `bg-primary` resolves to `bg-primary-600 dark:bg-primary-500`. So an active tab underlined in primary-500 next to a primary-600 button: one shade off, and with no `dark:` half at all, against this project's own rule that light and dark are always paired. Now `selected:border-primary-600 dark:selected:border-primary-500`, matching the `dark:hover:` prefix order already used in five places here. The test asserted "more than one distinct bottom-border colour", which pins the indicator away from the rule and to nothing: `border-red-500` kept it green. It now asserts the indicator IS the brand shade, in light and in dark, and both assertions fail on the bare token. Two things measured rather than guessed while writing them. The colour comes from `WindThemeData().colors['primary']` rather than a hex, because the first attempt used Material's `Colors.blue.shade600` (#1E88E5) while wind's default primary is Tailwind blue (#2563EB). And the dark case needs `WindThemeData(brightness: dark, syncWithSystem: false)`: a `MediaQuery` platform-brightness override does not reach wind, which reads its own theme data, so the first version of that case asserted light twice and looked fine. Plus the `[Unreleased]` entry the post-change checklist asks for. --- CHANGELOG.md | 4 ++ lib/src/ui/components/tabs/tabs.recipe.dart | 2 +- test/ui/components/tabs/tabs_test.dart | 67 +++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) 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 69aba13..c38523e 100644 --- a/lib/src/ui/components/tabs/tabs.recipe.dart +++ b/lib/src/ui/components/tabs/tabs.recipe.dart @@ -22,7 +22,7 @@ Map tabsRecipe({ // 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-primary', + '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 3b4ecc3..0ca0dd5 100644 --- a/test/ui/components/tabs/tabs_test.dart +++ b/test/ui/components/tabs/tabs_test.dart @@ -107,6 +107,60 @@ void main() { 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 { @@ -148,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]!; +}