Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion lib/src/ui/components/tabs/tabs.recipe.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ Map<String, String> 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',
},
);
Expand Down
103 changes: 103 additions & 0 deletions test/ui/components/tabs/tabs_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Color> bottomBorders = tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((box) => box.decoration)
.whereType<BoxDecoration>()
.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<Color> bottomBorders = tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((box) => box.decoration)
.whereType<BoxDecoration>()
.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(
Expand Down Expand Up @@ -112,3 +202,16 @@ void main() {
});
});
}

/// The brand colour wind resolves `primary-<shade>` 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]!;
}