From d3707a546d73fd5bac7a986192766132ea9b21a3 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 10:16:43 +0200 Subject: [PATCH 01/10] =?UTF-8?q?feat(badge):=20Badge=20primitive=20?= =?UTF-8?q?=E2=80=94=20non-interactive=20variant=20pill=20(primary=20base)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gallery/lib/components/ui/badge.dart | 119 +++++++++++++++++++++ apps/gallery/test/badge_behavior_test.dart | 39 +++++++ 2 files changed, 158 insertions(+) create mode 100644 apps/gallery/lib/components/ui/badge.dart create mode 100644 apps/gallery/test/badge_behavior_test.dart diff --git a/apps/gallery/lib/components/ui/badge.dart b/apps/gallery/lib/components/ui/badge.dart new file mode 100644 index 0000000..7171447 --- /dev/null +++ b/apps/gallery/lib/components/ui/badge.dart @@ -0,0 +1,119 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; + +/// shadcn's badge variants. `primary` is shadcn's `default` (`default` is a Dart +/// reserved word, so it cannot be an enum constant). shadcn's Badge has a single +/// size, so there is no size enum. +enum BadgeVariant { primary, secondary, destructive, outline } + +/// A Material-free, themeable badge — shadcn parity. Copy-paste source you own. +/// +/// **Intention-revealing:** `Badge(child: Text('New'))` reads as the artifact. A +/// badge is a tiny pill label whose fill/foreground/border come entirely from +/// `flutterwindcss` semantic tokens (`context.fw.colors.*`), so any pasted theme +/// reskins it. Layout is faithful to shadcn v4 `rounded-full border px-2 py-0.5 +/// text-xs font-medium gap-1`. +/// +/// **Variant pattern (mirrors [Button], without the state machine).** The variant +/// branches the *palette* in Dart via an exhaustive `switch` over [BadgeVariant] +/// (the cva equivalent), then a single flat `.tw` chain renders one styled box. +/// Because a Badge owns no action, it is a plain [StatelessWidget] — no +/// hover/focus/pressed, no `FocusableActionDetector`. +/// +/// **Non-interactive** (shadcn's default badge is a ``; the `[a&]:hover:` +/// states apply only to a link badge). For a clickable badge, wrap it (e.g. in a +/// `GestureDetector`/`Button`); clickability is intentionally not baked in here. +/// +/// **Sizes to its content** (`w-fit`): a bare `Text` child shrink-wraps; with a +/// [leading]/[trailing] icon the row is `MainAxisSize.min`. In a *stretch* parent +/// (e.g. a `CrossAxisAlignment.stretch` column) the box would stretch — place a +/// Badge inline (`FwRow`/`Wrap`) or wrap it in an `Align`. +/// +/// **Faithful-token deviations (engine, not bugs):** shadcn v4's destructive badge +/// uses a literal `text-white`; this uses the `destructiveForeground` token +/// (≈ white in the stock theme, and it reskins) per AGENTS.md §3.1, matching +/// [Button]. shadcn dims dark-mode destructive to `bg-destructive/60`; this uses +/// the flat `destructive` token in both brightnesses, also matching [Button]. +class Badge extends StatelessWidget { + const Badge({ + super.key, + required this.child, + this.variant = BadgeVariant.primary, + this.leading, + this.trailing, + this.semanticLabel, + }); + + /// The badge's label (typically a `Text`). For an icon-only badge, pass the + /// icon as [child] **and** a [semanticLabel]. + final Widget child; + + /// The badge's visual style (fill, foreground, border). Defaults to + /// [BadgeVariant.primary] (shadcn's `default`). + final BadgeVariant variant; + + /// Optional icon before the label (composed with a `gap-1`). Icons render at + /// 12px (shadcn `[&>svg]:size-3`). + final Widget? leading; + + /// Optional icon after the label. See [leading]. + final Widget? trailing; + + /// Screen-reader label. When provided it **replaces** the child's semantics + /// (like `aria-label`); omit it to announce the child's own text. **Required in + /// practice for icon-only badges**, whose child carries no text. + final String? semanticLabel; + + /// Resting treatment per variant. Transparent fill/border uses the one allowed + /// color literal (`Color(0x00000000)`, AGENTS.md §3.1). + ({Color bg, Color fg, Color border}) _palette(FwColors c) { + const transparent = Color(0x00000000); + return switch (variant) { + BadgeVariant.primary => (bg: c.primary, fg: c.primaryForeground, border: transparent), + BadgeVariant.secondary => (bg: c.secondary, fg: c.secondaryForeground, border: transparent), + BadgeVariant.destructive => ( + bg: c.destructive, + fg: c.destructiveForeground, + border: transparent, + ), + BadgeVariant.outline => (bg: transparent, fg: c.foreground, border: c.border), + }; + } + + @override + Widget build(BuildContext context) { + final p = _palette(context.fw.colors); + + // Compose the content: icon+label row (shadcn `gap-1`) when a leading/trailing + // slot is present; otherwise the bare label. Icons render at 12px (shadcn + // `[&>svg]:size-3`); their color flows from `.text(fg)` via the engine's + // IconTheme. + Widget content = child; + if (leading != null || trailing != null) { + content = FwRow( + gap: 1, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [if (leading != null) leading!, child, if (trailing != null) trailing!], + ); + } + content = IconTheme.merge(data: const IconThemeData(size: 12), child: content); + + Widget badge = content.tw + .bg(p.bg) + .text(p.fg) + .textSize(FwFontSize.xs.px) + .weight(FwFontWeight.medium) + .px(2) + .py(0.5) + .border(1, color: p.border) + .roundedFull + .nowrap + .clip(); + + if (semanticLabel != null) { + badge = Semantics(label: semanticLabel, child: badge); + } + return badge; + } +} diff --git a/apps/gallery/test/badge_behavior_test.dart b/apps/gallery/test/badge_behavior_test.dart new file mode 100644 index 0000000..20f0589 --- /dev/null +++ b/apps/gallery/test/badge_behavior_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; +import 'package:flutterbits_gallery/components/ui/badge.dart'; + +/// Theme frame. Badges are content-sized, so a topStart Align with no width +/// constraint lets the badge shrink-wrap its label (proving `w-fit`). +Widget _frame(FwTokens tokens, TextDirection dir, Widget child) => FwTheme( + tokens: tokens, + child: Directionality( + textDirection: dir, + child: MediaQuery( + data: const MediaQueryData(), + child: ColoredBox( + color: tokens.colors.background, + child: Align(alignment: AlignmentDirectional.topStart, child: child), + ), + ), + ), +); + +void main() { + testWidgets('primary Badge paints the primary token and renders its label', (t) async { + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, const Badge(child: Text('New'))), + ); + expect(t.takeException(), isNull); + expect(find.text('New'), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == FwTokens.light.colors.primary, + ), + findsOneWidget, + ); + }); +} From 8ea363d2fd57d405f222d0c3b7c89fe84ea95463 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 10:17:25 +0200 Subject: [PATCH 02/10] test(badge): lock per-variant fill tokens + outline border-token border --- apps/gallery/test/badge_behavior_test.dart | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/gallery/test/badge_behavior_test.dart b/apps/gallery/test/badge_behavior_test.dart index 20f0589..d75ce75 100644 --- a/apps/gallery/test/badge_behavior_test.dart +++ b/apps/gallery/test/badge_behavior_test.dart @@ -36,4 +36,50 @@ void main() { findsOneWidget, ); }); + + testWidgets('secondary/destructive Badges paint their fill tokens', (t) async { + for (final (variant, token) in <(BadgeVariant, Color)>[ + (BadgeVariant.secondary, FwTokens.light.colors.secondary), + (BadgeVariant.destructive, FwTokens.light.colors.destructive), + ]) { + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, Badge(variant: variant, child: const Text('x'))), + ); + expect(t.takeException(), isNull); + expect( + find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == token, + ), + findsOneWidget, + reason: 'variant $variant should fill with its token', + ); + } + }); + + testWidgets('outline Badge has a transparent fill and a border-token border', (t) async { + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + const Badge(variant: BadgeVariant.outline, child: Text('Draft')), + ), + ); + expect(t.takeException(), isNull); + expect( + find.byWidgetPredicate((w) { + if (w is! DecoratedBox) return false; + final d = w.decoration; + if (d is! BoxDecoration) return false; + final b = d.border; + return d.color == const Color(0x00000000) && + b is Border && + b.top.width > 0 && + b.top.color == FwTokens.light.colors.border; + }), + findsOneWidget, + ); + }); } From 80e277e87f0ca0997042874f9f047ba1e81dd679 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 10:19:39 +0200 Subject: [PATCH 03/10] test(badge): icon slots (gap-1 row) + semanticLabel replacement --- apps/gallery/test/badge_behavior_test.dart | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/gallery/test/badge_behavior_test.dart b/apps/gallery/test/badge_behavior_test.dart index d75ce75..6a5ceed 100644 --- a/apps/gallery/test/badge_behavior_test.dart +++ b/apps/gallery/test/badge_behavior_test.dart @@ -82,4 +82,45 @@ void main() { findsOneWidget, ); }); + + testWidgets('leading/trailing slots compose a gap-1 row around the label', (t) async { + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + const Badge( + leading: SizedBox(width: 12, height: 12, key: ValueKey('lead')), + trailing: SizedBox(width: 12, height: 12, key: ValueKey('trail')), + child: Text('Verified'), + ), + ), + ); + expect(t.takeException(), isNull); + expect(find.text('Verified'), findsOneWidget); + expect(find.byKey(const ValueKey('lead')), findsOneWidget); + expect(find.byKey(const ValueKey('trail')), findsOneWidget); + // FwRow lowers to Flex (not Row); gap:1 → spacing = fwSpace(1) = 4.0 px. + expect( + find.byWidgetPredicate((w) => w is Flex && w.spacing == 4.0), + findsOneWidget, + reason: 'gap-1 => spacing 4 on the icon+label Flex', + ); + }); + + testWidgets('semanticLabel replaces the child semantics (icon-only badge)', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + const Badge( + semanticLabel: 'online', + child: SizedBox(width: 12, height: 12), + ), + ), + ); + expect(t.takeException(), isNull); + expect(find.bySemanticsLabel('online'), findsOneWidget); + handle.dispose(); + }); } From da1ff1daee025d2ea124fc9476aeef3ff99110db Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 10:20:20 +0200 Subject: [PATCH 04/10] test(badge): RTL composition + theme-reskin regression coverage --- apps/gallery/test/badge_behavior_test.dart | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/gallery/test/badge_behavior_test.dart b/apps/gallery/test/badge_behavior_test.dart index 6a5ceed..20c314a 100644 --- a/apps/gallery/test/badge_behavior_test.dart +++ b/apps/gallery/test/badge_behavior_test.dart @@ -123,4 +123,49 @@ void main() { expect(find.bySemanticsLabel('online'), findsOneWidget); handle.dispose(); }); + + testWidgets('renders under RTL with no overflow/exception', (t) async { + await t.binding.setSurfaceSize(const Size(400, 200)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.rtl, + const Badge( + leading: SizedBox(width: 12, height: 12), + variant: BadgeVariant.outline, + child: Text('مسودة'), + ), + ), + ); + expect(t.takeException(), isNull); + expect(find.text('مسودة'), findsOneWidget); + }); + + testWidgets('reskins with the active theme (dark primary token)', (t) async { + await t.pumpWidget( + _frame(FwTokens.dark, TextDirection.ltr, const Badge(child: Text('x'))), + ); + expect(t.takeException(), isNull); + expect( + find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == FwTokens.dark.colors.primary, + ), + findsOneWidget, + ); + if (FwTokens.light.colors.primary != FwTokens.dark.colors.primary) { + expect( + find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == FwTokens.light.colors.primary, + ), + findsNothing, + ); + } + }); } From 4eeb636a4d698c3901343d8dd291da122d8b45bb Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 10:27:43 +0200 Subject: [PATCH 05/10] style(badge): dart format --line-length 100 --- apps/gallery/lib/components/ui/badge.dart | 23 +++++++++++----------- apps/gallery/test/badge_behavior_test.dart | 13 +++--------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/apps/gallery/lib/components/ui/badge.dart b/apps/gallery/lib/components/ui/badge.dart index 7171447..c98e492 100644 --- a/apps/gallery/lib/components/ui/badge.dart +++ b/apps/gallery/lib/components/ui/badge.dart @@ -99,17 +99,18 @@ class Badge extends StatelessWidget { } content = IconTheme.merge(data: const IconThemeData(size: 12), child: content); - Widget badge = content.tw - .bg(p.bg) - .text(p.fg) - .textSize(FwFontSize.xs.px) - .weight(FwFontWeight.medium) - .px(2) - .py(0.5) - .border(1, color: p.border) - .roundedFull - .nowrap - .clip(); + Widget badge = + content.tw + .bg(p.bg) + .text(p.fg) + .textSize(FwFontSize.xs.px) + .weight(FwFontWeight.medium) + .px(2) + .py(0.5) + .border(1, color: p.border) + .roundedFull + .nowrap + .clip(); if (semanticLabel != null) { badge = Semantics(label: semanticLabel, child: badge); diff --git a/apps/gallery/test/badge_behavior_test.dart b/apps/gallery/test/badge_behavior_test.dart index 20c314a..dd66169 100644 --- a/apps/gallery/test/badge_behavior_test.dart +++ b/apps/gallery/test/badge_behavior_test.dart @@ -21,9 +21,7 @@ Widget _frame(FwTokens tokens, TextDirection dir, Widget child) => FwTheme( void main() { testWidgets('primary Badge paints the primary token and renders its label', (t) async { - await t.pumpWidget( - _frame(FwTokens.light, TextDirection.ltr, const Badge(child: Text('New'))), - ); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Badge(child: Text('New')))); expect(t.takeException(), isNull); expect(find.text('New'), findsOneWidget); expect( @@ -113,10 +111,7 @@ void main() { _frame( FwTokens.light, TextDirection.ltr, - const Badge( - semanticLabel: 'online', - child: SizedBox(width: 12, height: 12), - ), + const Badge(semanticLabel: 'online', child: SizedBox(width: 12, height: 12)), ), ); expect(t.takeException(), isNull); @@ -143,9 +138,7 @@ void main() { }); testWidgets('reskins with the active theme (dark primary token)', (t) async { - await t.pumpWidget( - _frame(FwTokens.dark, TextDirection.ltr, const Badge(child: Text('x'))), - ); + await t.pumpWidget(_frame(FwTokens.dark, TextDirection.ltr, const Badge(child: Text('x')))); expect(t.takeException(), isNull); expect( find.byWidgetPredicate( From abc6d9ba15bfbd2f5f36a2cd02029c276ba51a18 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 10:43:21 +0200 Subject: [PATCH 06/10] fix(badge): excludeSemantics replace + foreground/border/Flex test guards (review) --- apps/gallery/lib/components/ui/badge.dart | 16 ++++++- apps/gallery/test/badge_behavior_test.dart | 54 +++++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/apps/gallery/lib/components/ui/badge.dart b/apps/gallery/lib/components/ui/badge.dart index c98e492..c0b6240 100644 --- a/apps/gallery/lib/components/ui/badge.dart +++ b/apps/gallery/lib/components/ui/badge.dart @@ -65,7 +65,11 @@ class Badge extends StatelessWidget { final String? semanticLabel; /// Resting treatment per variant. Transparent fill/border uses the one allowed - /// color literal (`Color(0x00000000)`, AGENTS.md §3.1). + /// color literal (`Color(0x00000000)`, AGENTS.md §3.1). The filled variants + /// (primary/secondary/destructive) keep an **explicit transparent border** — + /// not `null` — so every variant occupies identical geometry (the 1px border + /// slot is always present, just invisible unless `outline` colors it). This is + /// shadcn's `border border-transparent` → `border-border`. ({Color bg, Color fg, Color border}) _palette(FwColors c) { const transparent = Color(0x00000000); return switch (variant) { @@ -97,6 +101,10 @@ class Badge extends StatelessWidget { children: [if (leading != null) leading!, child, if (trailing != null) trailing!], ); } + // Pin icons to 12px (shadcn `[&>svg]:size-3`) *independently of the text + // size* — faithful to shadcn, where icon size is decoupled from `text-xs`. + // (Color is intentionally not set here so it inherits the `.text(fg)` color + // the engine threads through its own `IconTheme`.) content = IconTheme.merge(data: const IconThemeData(size: 12), child: content); Widget badge = @@ -113,7 +121,11 @@ class Badge extends StatelessWidget { .clip(); if (semanticLabel != null) { - badge = Semantics(label: semanticLabel, child: badge); + // `excludeSemantics: true` makes the label genuinely *replace* the child's + // semantics (like `aria-label`); without it the child's own text leaks and + // a screen reader announces both (verified). Icon-only badges therefore get + // exactly the supplied name, and a text badge with an override gets only it. + badge = Semantics(label: semanticLabel, excludeSemantics: true, child: badge); } return badge; } diff --git a/apps/gallery/test/badge_behavior_test.dart b/apps/gallery/test/badge_behavior_test.dart index dd66169..1cf8a32 100644 --- a/apps/gallery/test/badge_behavior_test.dart +++ b/apps/gallery/test/badge_behavior_test.dart @@ -75,12 +75,34 @@ void main() { return d.color == const Color(0x00000000) && b is Border && b.top.width > 0 && + b.top.color != const Color(0x00000000) && // a visible border must appear b.top.color == FwTokens.light.colors.border; }), findsOneWidget, ); }); + testWidgets('foreground uses the variant token (destructive → destructiveForeground)', (t) async { + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + const Badge(variant: BadgeVariant.destructive, child: Text('x')), + ), + ); + expect(t.takeException(), isNull); + // The `.text(fg)` engine setter resolves to a DefaultTextStyle carrying the + // variant's foreground token — proving destructive maps to the + // `destructiveForeground` token (NOT a literal `text-white`). + expect( + find.byWidgetPredicate( + (w) => + w is DefaultTextStyle && w.style.color == FwTokens.light.colors.destructiveForeground, + ), + findsWidgets, + ); + }); + testWidgets('leading/trailing slots compose a gap-1 row around the label', (t) async { await t.pumpWidget( _frame( @@ -97,9 +119,17 @@ void main() { expect(find.text('Verified'), findsOneWidget); expect(find.byKey(const ValueKey('lead')), findsOneWidget); expect(find.byKey(const ValueKey('trail')), findsOneWidget); - // FwRow lowers to Flex (not Row); gap:1 → spacing = fwSpace(1) = 4.0 px. + // FwRow lowers to a horizontal Flex (not Row); gap:1 → spacing = fwSpace(1) = + // 4.0 px. Pin direction + mainAxisSize so the predicate matches exactly the + // `FwRow(gap:1, mainAxisSize:.min)` the badge builds, not an incidental Flex. expect( - find.byWidgetPredicate((w) => w is Flex && w.spacing == 4.0), + find.byWidgetPredicate( + (w) => + w is Flex && + w.direction == Axis.horizontal && + w.mainAxisSize == MainAxisSize.min && + w.spacing == 4.0, + ), findsOneWidget, reason: 'gap-1 => spacing 4 on the icon+label Flex', ); @@ -119,6 +149,23 @@ void main() { handle.dispose(); }); + testWidgets('semanticLabel replaces a TEXT child label (no double-announce)', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + const Badge(semanticLabel: 'override', child: Text('hidden-child')), + ), + ); + expect(t.takeException(), isNull); + // The supplied label is the node's exact accessible name… + expect(find.bySemanticsLabel('override'), findsOneWidget); + // …and the child's own text does NOT leak into the semantics (excludeSemantics). + expect(find.bySemanticsLabel(RegExp('hidden-child')), findsNothing); + handle.dispose(); + }); + testWidgets('renders under RTL with no overflow/exception', (t) async { await t.binding.setSurfaceSize(const Size(400, 200)); addTearDown(() => t.binding.setSurfaceSize(null)); @@ -149,6 +196,9 @@ void main() { ), findsOneWidget, ); + // The light-token-absent half is only meaningful when the two themes differ + // (they do in the stock theme, so this runs); the guard avoids a vacuous + // assertion if a custom theme were ever wired with equal light/dark primaries. if (FwTokens.light.colors.primary != FwTokens.dark.colors.primary) { expect( find.byWidgetPredicate( From c19640b26952223bf8793c8c2f6974d94a45cdfd Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 10:46:40 +0200 Subject: [PATCH 07/10] =?UTF-8?q?test(badge):=20golden=20grid=20(light/dar?= =?UTF-8?q?k/RTL)=20=E2=80=94=20provisional,=20CI=20Linux=20authoritative?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gallery/test/badge_golden_test.dart | 102 ++++++++++++++++++ apps/gallery/test/goldens/badge_grid_dark.png | Bin 0 -> 2138 bytes .../gallery/test/goldens/badge_grid_light.png | Bin 0 -> 2487 bytes apps/gallery/test/goldens/badge_grid_rtl.png | Bin 0 -> 2468 bytes 4 files changed, 102 insertions(+) create mode 100644 apps/gallery/test/badge_golden_test.dart create mode 100644 apps/gallery/test/goldens/badge_grid_dark.png create mode 100644 apps/gallery/test/goldens/badge_grid_light.png create mode 100644 apps/gallery/test/goldens/badge_grid_rtl.png diff --git a/apps/gallery/test/badge_golden_test.dart b/apps/gallery/test/badge_golden_test.dart new file mode 100644 index 0000000..d1b7229 --- /dev/null +++ b/apps/gallery/test/badge_golden_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; +import 'package:flutterbits_gallery/components/ui/badge.dart'; + +/// Theme frame: FwTheme + Directionality + MediaQuery + background surface. The +/// grid is wrapped in a RepaintBoundary so [matchesGoldenFile] captures a clean +/// boundary. +Widget _frame(FwTokens tokens, TextDirection dir, Widget child) => FwTheme( + tokens: tokens, + child: Directionality( + textDirection: dir, + child: MediaQuery( + data: const MediaQueryData(), + child: ColoredBox( + color: tokens.colors.background, + child: Align( + alignment: AlignmentDirectional.topStart, + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ), + ), +); + +/// A tiny 12px "icon" (deterministic, font-free) for the icon-slot golden row. +class _Dot extends StatelessWidget { + const _Dot(); + @override + Widget build(BuildContext context) { + final icon = IconTheme.of(context); + return SizedBox.square( + dimension: icon.size ?? 12, + child: DecoratedBox(decoration: BoxDecoration(color: icon.color, shape: BoxShape.circle)), + ); + } +} + +Widget _grid() => RepaintBoundary( + key: const ValueKey('badge_grid'), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + // Row 1: every variant, label-only. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Badge(child: Text('Primary')), + Badge(variant: BadgeVariant.secondary, child: Text('Secondary')), + Badge(variant: BadgeVariant.destructive, child: Text('Destructive')), + Badge(variant: BadgeVariant.outline, child: Text('Outline')), + ], + ), + SizedBox(height: 12), + // Row 2: icon slots (leading + trailing) — gap-1, 12px icons. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Badge(leading: _Dot(), child: Text('Verified')), + Badge(variant: BadgeVariant.outline, trailing: _Dot(), child: Text('Beta')), + ], + ), + ], + ), +); + +void main() { + const surfaceSize = Size(420, 240); + + testWidgets('badge grid — light LTR', (t) async { + await t.binding.setSurfaceSize(surfaceSize); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, _grid())); + await expectLater( + find.byKey(const ValueKey('badge_grid')), + matchesGoldenFile('goldens/badge_grid_light.png'), + ); + }); + + testWidgets('badge grid — dark LTR', (t) async { + await t.binding.setSurfaceSize(surfaceSize); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(_frame(FwTokens.dark, TextDirection.ltr, _grid())); + await expectLater( + find.byKey(const ValueKey('badge_grid')), + matchesGoldenFile('goldens/badge_grid_dark.png'), + ); + }); + + testWidgets('badge grid — light RTL (icon slots mirror)', (t) async { + await t.binding.setSurfaceSize(surfaceSize); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.rtl, _grid())); + await expectLater( + find.byKey(const ValueKey('badge_grid')), + matchesGoldenFile('goldens/badge_grid_rtl.png'), + ); + }); +} diff --git a/apps/gallery/test/goldens/badge_grid_dark.png b/apps/gallery/test/goldens/badge_grid_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..fc2f6cb029ccecd83ef0770872fa835ab2f6786a GIT binary patch literal 2138 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 literal 0 HcmV?d00001 diff --git a/apps/gallery/test/goldens/badge_grid_rtl.png b/apps/gallery/test/goldens/badge_grid_rtl.png new file mode 100644 index 0000000000000000000000000000000000000000..591617222021c3146a25ed6e0037d1894679786e GIT binary patch literal 2468 zcmZ`*c{tSD8~=`d8w`@{zPFNz23<>*k{L_F6*JZ%YsHK$(hM;oGqzh2C8@X(U%M_T z%aCn`M8eP*rfdlI%GQn6H~TyLXkSwc;+66+P?>cZ}q z#F^-|IgPcCUDlfq`9*CDh=Tp0*7*uBJWo}yx7bi^7tqPCD?T#&e%bFUQNPDe*BHBg z3ksG&4Cii`I1hy(_U+BM9824MF`r*s8z~43x2+PtH3AP#AI#j{X!KiBktX+1vYmi& z{(g=PG^M9cK1#T^B+a>W%m9MEFf!GjVzbtrjq!+M@5aW))It9tbaZqBDwFX*3NhT) zU5#6}MP|*Tmo{7g)#Pk@!Fqr*eIx4cxjPEUj#>Bb=%rR@Xb%I5_>Qlx8qv0Pc9HVF zM~deQNUSPv^bOs@tkB(^?U=h=*mcf*y&xNiVy46_q)CPA&tqvBai01}1aOF^TIOEhT}LG?tsgIq#;oF=Z5_4}cl7zaD6*QQJ-`Rk3(f zORZ9d5~-LG$>Q7JVP<`IR?C#SyiKvfU14Am4E~6h{xVODm}jr|*t^*Ej!x3Lvr18# zj>YGbI5&wQZyOxN7NCvqc|5U*r?X@R_O?`Rq;zb?G&exqq|z{OV0LXYWf*Og*DM;l zgH33TFuK+Um>n)UArRWLm`2a#qw?u&Un*JA($Xq>{$BP{aPW~A%C{L)UEnKH=pXxY zY-3YQ+7<)N4Tg!{X#`>J)6u8$NA54 zC?S?cJ0YL`=6o#rUZz4EyR_dwixj#?Xs1)@TGg(2b+RX7l%7!C^D$2Fv_&U@r7TWq+s&cTlguzU zHRYRee4HvM76ktS!w<`dn1&lyy5F|m@5xhThl}Dd6yO}~sj+`u{!C3Wh6-IrY z7-9&8Glihpgx2%#7thY)JUl!^TJ8el-e^_OAQJdKzPMkAiFuL$kEf1(O_?-gav>86 z2NZY^F9HMC=ZET8rv@8vrOu}OoL~Orf^~$DFymyBfw3{`0BA5ONu}E%aQ^7f94el_ zg`1Eioz4gR%a%rYo}aHASr7E_`N)r$QDPs06sPn0O8TPE1q*`c8y1^#ZPoKGSkB)f9; zCmIMToqwz|Bgm?&q_lnT8hcZkJ$2vg>7*eW3>Ry0>!}%kO!8ZG)P_U6A$QY)`yPV_irrynd+SJ6p%kZ;LrZ)ic+3FtQIpxp= z#}cdx`RRGIYGq1?4>Mh-1h2mp5FNeMx9Q{V?!E&-aRODpMP0&Tnjb!Vc--rvS7n@9 z&rg&YahY?HE_c)~&26v94NtAeoBt)T#>UXa4{ekz+q>N|;uO(}Mb>kPhNv%=E-rfk z&P;Ev+`fIS9jSX$3C2Z`pi{}995(`cFyp%!?zuIC>|%h}GRu54{jz`mc$C~xMzAaV4| zA-#Hu3>o~S{w;vWkW zG}^_Z0C{OCn5)K>Nd5*|edjQOjDQ%gi)V1Vj`Nn&?Zf0CaNvyjSoR+J4WwV+L({CZ z#a~q-c7#0$I$d^mE#CPLl~`tY@890Nxs=sLv;HEUuZ8algiwOWA1EkpDGVZis0nBL;r{ zNn-g8Ff$SSpa9Llqw;CC2+=|keY zyu9=P(T(M~xl0dBh&3bn*XAOAC`%M&sCP`RoIwx=M@Ft1N($M|q?T;F-ShV1?<0eQ zzdwOc?%6|5o%(PBfnb1*{QQbv+4eG1H@$+oKa;Tt;Pv@d%dVR2sF9!JBKzgm5(q42 z+lOUT@a%NaS?Ny^icdW-f5Hrd+=jQNmogQC4OGD|VfE*FYq-{5Ha4vOdb=;QUWusG x<%OzIoUDw-MM`&hF%w^`Frxr5lw#brSHau+vPRlk4DL4zIAvjvsW3l({U7k0ky!u$ literal 0 HcmV?d00001 From 4cb621370aba0e47b3b2b6cb171310cbde073dde Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 10:48:05 +0200 Subject: [PATCH 08/10] feat(gallery): showcase Badge variants; smoke-test; charter progress note --- apps/gallery/lib/main.dart | 16 ++++++++++++++++ apps/gallery/test/gallery_smoke_test.dart | 1 + .../specs/2026-06-10-flutterbits-charter.md | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/gallery/lib/main.dart b/apps/gallery/lib/main.dart index 5c5460b..28f14f2 100644 --- a/apps/gallery/lib/main.dart +++ b/apps/gallery/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutterwindcss/flutterwindcss.dart'; import 'components/ui/button.dart'; import 'components/ui/card.dart'; +import 'components/ui/badge.dart'; void main() => runApp(const GalleryApp()); @@ -112,6 +113,21 @@ class GalleryApp extends StatelessWidget { ], ), ), + const SizedBox(height: 24), + Text('Badges').tw.textSize(20).weight(FwFontWeight.bold), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: const [ + Badge(child: Text('Primary')), + Badge(variant: BadgeVariant.secondary, child: Text('Secondary')), + Badge(variant: BadgeVariant.destructive, child: Text('Destructive')), + Badge(variant: BadgeVariant.outline, child: Text('Outline')), + Badge(leading: _Dot(), child: Text('Verified')), + ], + ), ], ), ), diff --git a/apps/gallery/test/gallery_smoke_test.dart b/apps/gallery/test/gallery_smoke_test.dart index 2a5d43f..899cc4b 100644 --- a/apps/gallery/test/gallery_smoke_test.dart +++ b/apps/gallery/test/gallery_smoke_test.dart @@ -11,5 +11,6 @@ void main() { expect(t.takeException(), isNull); expect(find.text('primary'), findsWidgets); expect(find.text('Create project'), findsWidgets); + expect(find.text('Badges'), findsWidgets); }); } diff --git a/docs/superpowers/specs/2026-06-10-flutterbits-charter.md b/docs/superpowers/specs/2026-06-10-flutterbits-charter.md index dd26fb5..ab7186b 100644 --- a/docs/superpowers/specs/2026-06-10-flutterbits-charter.md +++ b/docs/superpowers/specs/2026-06-10-flutterbits-charter.md @@ -167,7 +167,7 @@ This charter is the umbrella. Implementation is decomposed into specs, each its **First vertical slice (proves the whole stack end-to-end):** `Layout` + `Screen` + routing + `Button` + `ThemeToggle`, rendered in **`apps/gallery`** — a **new** flutterbits component showcase + golden/compile target, created with this slice and kept separate from the engine's `apps/example` (decision 2026-06-10). `ThemeToggle` is the chosen first concrete component — tiny, pure "feel good," and it forces every layer to play together (`Layout` owning theme → the `Switch` primitive → semantic-token reskin → the engine's `FwAnimatedTheme` transition). -> **Sequencing update (2026-06-15):** after `Button` proved the component-authoring stack end-to-end (PRs #45–#47 — `apps/gallery` scaffold, CI gating, canonical audit), the **primitives catalog (§3.2) is being built out before the structure layer (item 2 above)**. Rationale: it is lower-risk, reuses the proven component pattern, and several structure pieces depend on primitives anyway (e.g. `ThemeToggle` → a `Switch` primitive). `Card` (2026-06-15) is the first post-`Button` primitive and the canonical template for **compound, non-interactive** components (composed subcomponents, slot-based regions; see `apps/gallery/lib/components/ui/card.dart`). The structure-and-routing spec remains next-in-line as an epic — this is a **sequencing** change, not a scope change. +> **Sequencing update (2026-06-15):** after `Button` proved the component-authoring stack end-to-end (PRs #45–#47 — `apps/gallery` scaffold, CI gating, canonical audit), the **primitives catalog (§3.2) is being built out before the structure layer (item 2 above)**. Rationale: it is lower-risk, reuses the proven component pattern, and several structure pieces depend on primitives anyway (e.g. `ThemeToggle` → a `Switch` primitive). `Card` (2026-06-15) is the first post-`Button` primitive and the canonical template for **compound, non-interactive** components (composed subcomponents, slot-based regions; see `apps/gallery/lib/components/ui/card.dart`). `Badge` (2026-06-15) follows as a **variant-based, non-interactive single-box** primitive (`Button`'s variant discipline without its state machine; see `apps/gallery/lib/components/ui/badge.dart`). The structure-and-routing spec remains next-in-line as an epic — this is a **sequencing** change, not a scope change. --- From a2e49f6d7d558740ecb9e36613d61dd47fc1b069 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 10:51:08 +0200 Subject: [PATCH 09/10] docs(plan): Badge primitive implementation plan --- .../plans/2026-06-15-flutterbits-badge.md | 775 ++++++++++++++++++ 1 file changed, 775 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-flutterbits-badge.md diff --git a/docs/superpowers/plans/2026-06-15-flutterbits-badge.md b/docs/superpowers/plans/2026-06-15-flutterbits-badge.md new file mode 100644 index 0000000..ced2357 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-flutterbits-badge.md @@ -0,0 +1,775 @@ +# Badge primitive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `Badge` — the third flutterbits primitive (after `Button` and `Card`) — as Material-free, themeable, shadcn-v4-faithful copy-paste source in `apps/gallery`, with behavior + golden tests and CI gating. + +**Architecture:** A `Badge` is a tiny, **non-interactive** pill: a single `.tw` box (`rounded-full border px-2 py-0.5 text-xs font-medium`) whose fill/foreground/border come entirely from `flutterwindcss` semantic tokens, branched per variant by a typed enum + exhaustive `switch` (the cva equivalent, mirroring `Button`). It is the canonical shape for a **variant-based, non-interactive, single-box** primitive — `Button`'s variant discipline without `Button`'s interaction-state machine, and `Card`'s non-interactive stance without `Card`'s composition. Optional `leading`/`trailing` icon slots compose a `gap-1` row exactly like `Button`'s. + +**Tech Stack:** Dart / Flutter (`package:flutter/widgets.dart` only — no Material), `flutterwindcss` engine (`.tw`, `context.fw`, `FwRow`), `flutter_test` goldens (CI Linux authoritative). + +--- + +## Grounding (read before starting) + +**Authoritative shadcn v4 Badge classes** (from `ui.shadcn.com/r/styles/new-york-v4/badge.json`, verbatim, fetched 2026-06-15): + +| Part | className | +|---|---| +| `Badge` (base) | `inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:… [&>svg]:pointer-events-none [&>svg]:size-3` | +| variant `default` | `bg-primary text-primary-foreground [a&]:hover:bg-primary/90` | +| variant `secondary` | `bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90` | +| variant `destructive` | `bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90` | +| variant `outline` | `border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground` | + +**How each class maps (and what is intentionally dropped, recorded not silent):** +- `rounded-full` → `.roundedFull` (engine getter ⇒ radius 9999, a pill). `overflow-hidden` → `.clip()` (clips content to the pill — never silently no-ops; engine `fw_style_ops.dart:513`). +- `border border-transparent` (base) + outline's `border-border`: **every** variant carries a 1px border. primary/secondary/destructive use a **transparent** border (`Color(0x00000000)` — the *one* literal AGENTS.md §3.1 allows); outline uses the `border` token (`c.border`). Keeping the border on all variants makes the box geometry identical across variants regardless of how the engine lays out a border — and is exactly shadcn's `border border-transparent` → `border-border`. +- `px-2 py-0.5` → `.px(2)` (8px) + `.py(0.5)` (2px; fractional units are fine — `Button` uses `.py(1.5)`/`.py(2.5)`). `text-xs` → `.textSize(FwFontSize.xs.px)` (=12). `font-medium` → `.weight(FwFontWeight.medium)` (=500). `whitespace-nowrap` → `.nowrap` (getter). +- `gap-1` → `FwRow(gap: 1)` (=4px) for the icon+label row. `[&>svg]:size-3` → icons render at 12px via `IconTheme.merge(size: 12)`. (The engine already sets descendant `IconTheme(color: foreground, size: fontSize)` from `.text(...)` — `resolved_style_build.dart:76` — so the merge only needs to pin the 12px size; color flows from `.text`.) +- `w-fit shrink-0 items-center justify-center` → the badge **sizes to its content** (a bare `Text` shrink-wraps; the icon row uses `MainAxisSize.min`, `CrossAxisAlignment.center`). Documented limitation: in a **stretch** parent (e.g. a `CrossAxisAlignment.stretch` column) the box would stretch — place a Badge inline (`FwRow`/`Wrap`) or wrap it in an `Align`. (This matches how any content-sized `.tw` box behaves.) +- **`text-white` (destructive) → `c.destructiveForeground`** (NOT a literal). AGENTS.md §3.1 bans color literals except transparent; `Button` already maps destructive's foreground to `c.destructiveForeground` (≈ white in the stock theme, so visually identical **and** it reskins). Mirror `Button` for consistency. **Recorded deviation, not silent.** +- **`dark:bg-destructive/60` → dropped (flat `c.destructive` both brightnesses)**, matching `Button`. The token system exposes one `destructive` per brightness; a dark-only 60%-opacity refinement would require the component to detect brightness (no clean API) and diverge from `Button`. **Recorded deviation in the class doc, not silent** (§12). +- **`focus-visible:*` / `aria-invalid:*` / `[a&]:hover:*` → not applicable.** These fire only when the badge is rendered as a link/`` (`[a&]`) or a focusable/invalid form control. flutterbits' `Badge` is **non-interactive** (shadcn's default badge is a ``): no focus ring, no hover, no keyboard machinery. For a clickable badge, wrap it (e.g. in a `GestureDetector`/`Button`) — documented, consistent with `Card`'s non-interactive stance. This is faithful: a non-interactive badge correctly has no interaction states. + +**Engine API facts verified against `packages/flutterwindcss/lib/src/style/fw_style_ops.dart` + `tokens/typography.dart`:** +- `.bg(Color)`, `.text(Color)` (sets foreground ⇒ `DefaultTextStyle.merge` **and** `IconTheme`, cascading to descendant text/icons), `.border(width, {color})` (all-sides), `.roundedFull` (getter ⇒ `rounded(9999)`), `.clip([Clip])` (getter-like; default `Clip.antiAlias`), `.nowrap` (getter ⇒ `softWrap:false`). +- `.px(n)`/`.py(n)` directional padding in utility units (×4 logical px), fractional allowed. +- `.textSize(double)` (pass `FwFontSize.xs.px` = 12), `.weight(int)` (pass `FwFontWeight.medium` = 500; `FwFontWeight.*` are `int` consts), `FwFontSize.xs` exists (`typography.dart:9`). +- `FwRow(gap:, mainAxisSize:, crossAxisAlignment:, children:)` (passthrough to `Row` + `spacing`). +- `context.fw.colors.{primary,primaryForeground,secondary,secondaryForeground,destructive,destructiveForeground,foreground,border}` — all in the 32-token `FwColors` set. + +**Canonical patterns to mirror:** +- `apps/gallery/lib/components/ui/button.dart` — typed `enum` variants, exhaustive `switch` returning a **record** palette (no `late`), `primary`/`md` for the reserved word `default`, `leading`/`trailing` icon slots composed via `FwRow(gap:…, mainAxisSize:.min)` + `IconTheme.merge(size:…)`, optional `semanticLabel` that **replaces** child semantics (icon-only ⇒ required), file-header doc-comment (intention + theming + limitations). +- `apps/gallery/lib/components/ui/card.dart` — a **non-interactive `StatelessWidget`** primitive (no state machine), semantic tokens only. +- Analyzer-clean with `--fatal-infos --fatal-warnings`; `dart format` 100-col; one component per file. + +**Golden harness to mirror** (`apps/gallery/test/card_golden_test.dart`): `FwTheme(tokens:) → Directionality → MediaQuery → ColoredBox(background) → Align(topStart) → Padding → RepaintBoundary(key:)`, `setSurfaceSize`, `matchesGoldenFile('goldens/.png')`, three goldens (light LTR / dark LTR / light RTL). **CI (Linux) is the authoritative golden platform** (AGENTS.md §9). Local `--update-goldens` produces a *provisional* baseline only; the real baseline comes from the CI `gallery-golden-failures` artifact (Task 7 re-baseline recipe — the Button/Card precedent). + +**Design decisions locked for this plan (rationale above):** +1. `enum BadgeVariant { primary, secondary, destructive, outline }` — `primary` is shadcn `default` (Dart reserved word). **No size enum** — shadcn's Badge has a single size. +2. `Badge` is a **non-interactive `StatelessWidget`**. No hover/focus/pressed, no `FocusableActionDetector`, no `MinTapTarget`. Clickability is composed by wrapping (documented). +3. API: `Badge(child, {variant = primary, leading, trailing, semanticLabel})`. `leading`/`trailing` are optional icon `Widget?` slots (gap-1 row, icons 12px). `semanticLabel`, when non-null, wraps in `Semantics(label:)` (replaces child semantics — **required in practice for icon-only badges**). +4. Every variant applies `.border(1, color: …)`: `Color(0x00000000)` for primary/secondary/destructive, `c.border` for outline (faithful `border border-transparent` → `border-border`; the one allowed literal). +5. destructive foreground = `c.destructiveForeground` (token, not literal `text-white`); destructive fill = flat `c.destructive` both brightnesses (dropping `dark:bg-destructive/60`). Both recorded in the class doc. +6. The badge **sizes to its content** (`w-fit`): bare `Text` shrink-wraps; the icon row is `MainAxisSize.min`. Documented limitation for stretch parents. + +**No-drift scope:** Badge is *another primitive*, not a new canonical template (Button = interactive template; Card = compound template both stand). So docs edits are minimal: Task 6 only records that `Badge` shipped in the charter §3.2 progress note and verifies nothing else is falsified. The flutterbits docs tab stays overview-only ("components coming soon" — Button & Card are undocumented there too), so Badge adds **no** docs drift. + +**Deferred (recorded, not silently dropped):** registry promotion (`registry/badge.dart` + manifest + `tooling/build_registry.dart`) stays with the registry/CLI plan, exactly as for `button`/`card` — Badge lives at `apps/gallery/lib/components/ui/badge.dart` for now. A clickable/link badge (shadcn `[a&]`) is out of scope by decision (compose by wrapping); recorded in the class doc. + +--- + +## File structure + +- Create: `apps/gallery/lib/components/ui/badge.dart` — `BadgeVariant` enum + `Badge` (one file; one cohesive copy-paste unit, mirroring shadcn's single `badge.tsx`). +- Create: `apps/gallery/test/badge_behavior_test.dart` — variant/token, border, icon-slot, semantics, RTL, reskin assertions. +- Create: `apps/gallery/test/badge_golden_test.dart` — golden grid (light LTR / dark LTR / light RTL). +- Create: `apps/gallery/test/goldens/badge_grid_light.png`, `badge_grid_dark.png`, `badge_grid_rtl.png` (provisional locally; re-baselined from CI artifact). +- Modify: `apps/gallery/lib/main.dart` — add a Badge showcase section so CI compiles the component in the app. +- Modify: `apps/gallery/test/gallery_smoke_test.dart` — assert a Badge example renders. +- Modify: `docs/superpowers/specs/2026-06-10-flutterbits-charter.md` (§3.2/§8 progress note — Badge shipped). + +--- + +### Task 1: `BadgeVariant` enum + `Badge` (primary variant, base styling) + +**Files:** +- Create: `apps/gallery/lib/components/ui/badge.dart` +- Test: `apps/gallery/test/badge_behavior_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `apps/gallery/test/badge_behavior_test.dart`: + +```dart +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; +import 'package:flutterbits_gallery/components/ui/badge.dart'; + +/// Theme frame. Badges are content-sized, so a topStart Align with no width +/// constraint lets the badge shrink-wrap its label (proving `w-fit`). +Widget _frame(FwTokens tokens, TextDirection dir, Widget child) => FwTheme( + tokens: tokens, + child: Directionality( + textDirection: dir, + child: MediaQuery( + data: const MediaQueryData(), + child: ColoredBox( + color: tokens.colors.background, + child: Align(alignment: AlignmentDirectional.topStart, child: child), + ), + ), + ), +); + +void main() { + testWidgets('primary Badge paints the primary token and renders its label', (t) async { + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, const Badge(child: Text('New'))), + ); + expect(t.takeException(), isNull); + expect(find.text('New'), findsOneWidget); + // The fill uses the `primary` semantic token (theme-reskin proof). + expect( + find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == FwTokens.light.colors.primary, + ), + findsOneWidget, + ); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/gallery && flutter test test/badge_behavior_test.dart` +Expected: FAIL — `Badge`/`BadgeVariant` undefined (`badge.dart` does not exist). + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/gallery/lib/components/ui/badge.dart`: + +```dart +import 'package:flutter/widgets.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; + +/// shadcn's badge variants. `primary` is shadcn's `default` (`default` is a Dart +/// reserved word, so it cannot be an enum constant). shadcn's Badge has a single +/// size, so there is no size enum. +enum BadgeVariant { primary, secondary, destructive, outline } + +/// A Material-free, themeable badge — shadcn parity. Copy-paste source you own. +/// +/// **Intention-revealing:** `Badge(child: Text('New'))` reads as the artifact. A +/// badge is a tiny pill label whose fill/foreground/border come entirely from +/// `flutterwindcss` semantic tokens (`context.fw.colors.*`), so any pasted theme +/// reskins it. Layout is faithful to shadcn v4 `rounded-full border px-2 py-0.5 +/// text-xs font-medium gap-1`. +/// +/// **Variant pattern (mirrors [Button], without the state machine).** The variant +/// branches the *palette* in Dart via an exhaustive `switch` over [BadgeVariant] +/// (the cva equivalent), then a single flat `.tw` chain renders one styled box. +/// Because a Badge owns no action, it is a plain [StatelessWidget] — no +/// hover/focus/pressed, no [FocusableActionDetector]. +/// +/// **Non-interactive** (shadcn's default badge is a ``; the `[a&]:hover:` +/// states apply only to a link badge). For a clickable badge, wrap it (e.g. in a +/// `GestureDetector`/`Button`); clickability is intentionally not baked in here. +/// +/// **Sizes to its content** (`w-fit`): a bare `Text` child shrink-wraps; with a +/// [leading]/[trailing] icon the row is `MainAxisSize.min`. In a *stretch* parent +/// (e.g. a `CrossAxisAlignment.stretch` column) the box would stretch — place a +/// Badge inline (`FwRow`/`Wrap`) or wrap it in an `Align`. +/// +/// **Faithful-token deviations (engine, not bugs):** shadcn v4's destructive badge +/// uses a literal `text-white`; this uses the `destructiveForeground` token +/// (≈ white in the stock theme, and it reskins) per AGENTS.md §3.1, matching +/// [Button]. shadcn dims dark-mode destructive to `bg-destructive/60`; this uses +/// the flat `destructive` token in both brightnesses, also matching [Button]. +class Badge extends StatelessWidget { + const Badge({ + super.key, + required this.child, + this.variant = BadgeVariant.primary, + this.leading, + this.trailing, + this.semanticLabel, + }); + + /// The badge's label (typically a `Text`). For an icon-only badge, pass the + /// icon as [child] **and** a [semanticLabel]. + final Widget child; + + final BadgeVariant variant; + + /// Optional icon before the label (composed with a `gap-1`). Icons render at + /// 12px (shadcn `[&>svg]:size-3`). + final Widget? leading; + + /// Optional icon after the label. See [leading]. + final Widget? trailing; + + /// Screen-reader label. When provided it **replaces** the child's semantics + /// (like `aria-label`); omit it to announce the child's own text. **Required in + /// practice for icon-only badges**, whose child carries no text. + final String? semanticLabel; + + /// Resting treatment per variant. Transparent fill/border uses the one allowed + /// color literal (`Color(0x00000000)`, AGENTS.md §3.1). + ({Color bg, Color fg, Color border}) _palette(FwColors c) { + const transparent = Color(0x00000000); + return switch (variant) { + BadgeVariant.primary => (bg: c.primary, fg: c.primaryForeground, border: transparent), + BadgeVariant.secondary => (bg: c.secondary, fg: c.secondaryForeground, border: transparent), + BadgeVariant.destructive => ( + bg: c.destructive, + fg: c.destructiveForeground, + border: transparent, + ), + BadgeVariant.outline => (bg: transparent, fg: c.foreground, border: c.border), + }; + } + + @override + Widget build(BuildContext context) { + final p = _palette(context.fw.colors); + + // Compose the content: icon+label row (shadcn `gap-1`) when a leading/trailing + // slot is present; otherwise the bare label. Icons render at 12px (shadcn + // `[&>svg]:size-3`); their color flows from `.text(fg)` via the engine's + // IconTheme. + Widget content = child; + if (leading != null || trailing != null) { + content = FwRow( + gap: 1, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [if (leading != null) leading!, child, if (trailing != null) trailing!], + ); + } + content = IconTheme.merge(data: const IconThemeData(size: 12), child: content); + + Widget badge = content.tw + .bg(p.bg) + .text(p.fg) + .textSize(FwFontSize.xs.px) + .weight(FwFontWeight.medium) + .px(2) + .py(0.5) + .border(1, color: p.border) + .roundedFull + .nowrap + .clip(); + + if (semanticLabel != null) { + badge = Semantics(label: semanticLabel, child: badge); + } + return badge; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/gallery && flutter test test/badge_behavior_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/gallery/lib/components/ui/badge.dart apps/gallery/test/badge_behavior_test.dart +git commit -F +``` +Message: `feat(badge): Badge primitive — non-interactive variant pill (primary base)` + +--- + +### Task 2: All variants — tokens + outline border + +**Files:** +- Test: `apps/gallery/test/badge_behavior_test.dart` + +> The implementation from Task 1 already handles every variant (the exhaustive `switch`). This task locks each variant's token/border mapping with regression tests. + +- [ ] **Step 1: Write the failing test** (append inside `main()`) + +```dart + testWidgets('secondary/destructive Badges paint their fill tokens', (t) async { + for (final (variant, token) in <(BadgeVariant, Color)>[ + (BadgeVariant.secondary, FwTokens.light.colors.secondary), + (BadgeVariant.destructive, FwTokens.light.colors.destructive), + ]) { + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, Badge(variant: variant, child: const Text('x'))), + ); + expect(t.takeException(), isNull); + expect( + find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == token, + ), + findsOneWidget, + reason: 'variant $variant should fill with its token', + ); + } + }); + + testWidgets('outline Badge has a transparent fill and a border-token border', (t) async { + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + const Badge(variant: BadgeVariant.outline, child: Text('Draft')), + ), + ); + expect(t.takeException(), isNull); + expect( + find.byWidgetPredicate((w) { + if (w is! DecoratedBox) return false; + final d = w.decoration; + if (d is! BoxDecoration) return false; + final b = d.border; + // Transparent fill + a 1px border in the `border` token (all sides equal). + return d.color == const Color(0x00000000) && + b is Border && + b.top.width > 0 && + b.top.color == FwTokens.light.colors.border; + }), + findsOneWidget, + ); + }); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `cd apps/gallery && flutter test test/badge_behavior_test.dart` +Expected: PASS (the Task 1 implementation already satisfies these). If the outline assertion fails because the engine emits `BorderDirectional` rather than `Border` for an all-sides border, relax `b is Border` to `b is BoxBorder` and read `b.bottom`/`b.top` via the `BoxBorder` interface — verify which type the engine emits with a one-off `print(d.border.runtimeType)` first, then assert the real type (do not loosen the color/width assertion). + +- [ ] **Step 3: Commit** + +```bash +git add apps/gallery/test/badge_behavior_test.dart +git commit -F +``` +Message: `test(badge): lock per-variant fill tokens + outline border-token border` + +--- + +### Task 3: Icon slots (gap-1 row, 12px icons) + semanticLabel + +**Files:** +- Test: `apps/gallery/test/badge_behavior_test.dart` + +- [ ] **Step 1: Write the failing test** (append inside `main()`) + +```dart + testWidgets('leading/trailing slots compose a gap-1 row around the label', (t) async { + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + const Badge( + leading: SizedBox(width: 12, height: 12, key: ValueKey('lead')), + trailing: SizedBox(width: 12, height: 12, key: ValueKey('trail')), + child: Text('Verified'), + ), + ), + ); + expect(t.takeException(), isNull); + expect(find.text('Verified'), findsOneWidget); + expect(find.byKey(const ValueKey('lead')), findsOneWidget); + expect(find.byKey(const ValueKey('trail')), findsOneWidget); + // A Row carries the gap (spacing) between the icon and the label. + expect( + find.byWidgetPredicate((w) => w is Row && w.spacing == 4.0), + findsOneWidget, + reason: 'gap-1 => spacing 4 on the icon+label Row', + ); + }); + + testWidgets('semanticLabel replaces the child semantics (icon-only badge)', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + const Badge( + semanticLabel: 'online', + child: SizedBox(width: 12, height: 12), + ), + ), + ); + expect(t.takeException(), isNull); + expect(find.bySemanticsLabel('online'), findsOneWidget); + handle.dispose(); + }); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `cd apps/gallery && flutter test test/badge_behavior_test.dart` +Expected: PASS (Task 1 implements slots + semanticLabel). If `w.spacing == 4.0` fails, confirm `FwRow(gap: 1)` lowers to `Row(spacing: 4)` with a one-off `print` of the Row's `spacing`; if `FwRow` injects gap via `SizedBox` separators instead, assert the separators' presence instead (do not drop the icon/label assertions). + +- [ ] **Step 3: Commit** + +```bash +git add apps/gallery/test/badge_behavior_test.dart +git commit -F +``` +Message: `test(badge): icon slots (gap-1 row) + semanticLabel replacement` + +--- + +### Task 4: RTL + theming reskin behavior tests + +**Files:** +- Test: `apps/gallery/test/badge_behavior_test.dart` + +- [ ] **Step 1: Write the failing test** (append inside `main()`) + +```dart + testWidgets('renders under RTL with no overflow/exception', (t) async { + await t.binding.setSurfaceSize(const Size(400, 200)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.rtl, + const Badge( + leading: SizedBox(width: 12, height: 12), + variant: BadgeVariant.outline, + child: Text('مسودة'), + ), + ), + ); + expect(t.takeException(), isNull); + expect(find.text('مسودة'), findsOneWidget); + }); + + testWidgets('reskins with the active theme (dark primary token)', (t) async { + await t.pumpWidget( + _frame(FwTokens.dark, TextDirection.ltr, const Badge(child: Text('x'))), + ); + expect(t.takeException(), isNull); + expect( + find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == FwTokens.dark.colors.primary, + ), + findsOneWidget, + ); + // The light primary token must NOT appear (proves it actually reskinned), + // unless the stock light/dark primary happen to be equal. + if (FwTokens.light.colors.primary != FwTokens.dark.colors.primary) { + expect( + find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == FwTokens.light.colors.primary, + ), + findsNothing, + ); + } + }); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `cd apps/gallery && flutter test test/badge_behavior_test.dart` +Expected: PASS. A `RenderFlex` overflow in the RTL case is a real bug — fix the layout, do not loosen the assertion. + +- [ ] **Step 3: Run the full behavior suite** + +Run: `cd apps/gallery && flutter test test/badge_behavior_test.dart` +Expected: PASS (all cases). + +- [ ] **Step 4: Commit** + +```bash +git add apps/gallery/test/badge_behavior_test.dart +git commit -F +``` +Message: `test(badge): RTL composition + theme-reskin regression coverage` + +--- + +### Task 5: Golden grid (light / dark / RTL) + +**Files:** +- Create: `apps/gallery/test/badge_golden_test.dart` +- Create (provisional, then re-baselined on CI): `apps/gallery/test/goldens/badge_grid_{light,dark,rtl}.png` + +- [ ] **Step 1: Write the golden test** + +Create `apps/gallery/test/badge_golden_test.dart`: + +```dart +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; +import 'package:flutterbits_gallery/components/ui/badge.dart'; + +/// Theme frame: FwTheme + Directionality + MediaQuery + background surface. The +/// grid is wrapped in a RepaintBoundary so [matchesGoldenFile] captures a clean +/// boundary. +Widget _frame(FwTokens tokens, TextDirection dir, Widget child) => FwTheme( + tokens: tokens, + child: Directionality( + textDirection: dir, + child: MediaQuery( + data: const MediaQueryData(), + child: ColoredBox( + color: tokens.colors.background, + child: Align( + alignment: AlignmentDirectional.topStart, + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ), + ), +); + +/// A tiny 12px "icon" (deterministic, font-free) for the icon-slot golden row. +class _Dot extends StatelessWidget { + const _Dot(); + @override + Widget build(BuildContext context) { + final icon = IconTheme.of(context); + return SizedBox.square( + dimension: icon.size ?? 12, + child: DecoratedBox(decoration: BoxDecoration(color: icon.color, shape: BoxShape.circle)), + ); + } +} + +Widget _grid() => RepaintBoundary( + key: const ValueKey('badge_grid'), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + // Row 1: every variant, label-only. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Badge(child: Text('Primary')), + Badge(variant: BadgeVariant.secondary, child: Text('Secondary')), + Badge(variant: BadgeVariant.destructive, child: Text('Destructive')), + Badge(variant: BadgeVariant.outline, child: Text('Outline')), + ], + ), + SizedBox(height: 12), + // Row 2: icon slots (leading + trailing) — gap-1, 12px icons. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Badge(leading: _Dot(), child: Text('Verified')), + Badge(variant: BadgeVariant.outline, trailing: _Dot(), child: Text('Beta')), + ], + ), + ], + ), +); + +void main() { + const surfaceSize = Size(420, 240); + + testWidgets('badge grid — light LTR', (t) async { + await t.binding.setSurfaceSize(surfaceSize); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, _grid())); + await expectLater( + find.byKey(const ValueKey('badge_grid')), + matchesGoldenFile('goldens/badge_grid_light.png'), + ); + }); + + testWidgets('badge grid — dark LTR', (t) async { + await t.binding.setSurfaceSize(surfaceSize); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(_frame(FwTokens.dark, TextDirection.ltr, _grid())); + await expectLater( + find.byKey(const ValueKey('badge_grid')), + matchesGoldenFile('goldens/badge_grid_dark.png'), + ); + }); + + testWidgets('badge grid — light RTL (icon slots mirror)', (t) async { + await t.binding.setSurfaceSize(surfaceSize); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.rtl, _grid())); + await expectLater( + find.byKey(const ValueKey('badge_grid')), + matchesGoldenFile('goldens/badge_grid_rtl.png'), + ); + }); +} +``` + +- [ ] **Step 2: Run to verify it fails (no goldens yet)** + +Run: `cd apps/gallery && flutter test test/badge_golden_test.dart` +Expected: FAIL — golden files do not exist. + +- [ ] **Step 3: Generate provisional local goldens** + +Run: `cd apps/gallery && flutter test --update-goldens test/badge_golden_test.dart` +Then **open the three PNGs** in `apps/gallery/test/goldens/` and eyeball-verify layout/shape (text renders as deterministic boxes in the test font — check structure: pill shape, outline has a visible border ring while the filled variants don't, the icon dot sits before/after the label, RTL mirrors the icon side). Provisional only — CI Linux is authoritative (Task 7). + +- [ ] **Step 4: Run to verify the provisional goldens pass locally** + +Run: `cd apps/gallery && flutter test test/badge_golden_test.dart` +Expected: PASS (locally). + +- [ ] **Step 5: Commit** + +```bash +git add apps/gallery/test/badge_golden_test.dart apps/gallery/test/goldens/badge_grid_light.png apps/gallery/test/goldens/badge_grid_dark.png apps/gallery/test/goldens/badge_grid_rtl.png +git commit -F +``` +Message: `test(badge): golden grid (light/dark/RTL) — provisional, CI Linux authoritative` + +> Re-baselining on CI happens in Task 7 after the PR is open (Windows goldens are known to differ from CI Linux — Button/Card precedent). + +--- + +### Task 6: Gallery integration + smoke test + no-drift charter note + +**Files:** +- Modify: `apps/gallery/lib/main.dart` +- Modify: `apps/gallery/test/gallery_smoke_test.dart` +- Modify: `docs/superpowers/specs/2026-06-10-flutterbits-charter.md` + +- [ ] **Step 1: Update the smoke test (failing) to expect a Badge example** + +In `apps/gallery/test/gallery_smoke_test.dart`, add an assertion alongside the existing checks (after the `find.text('Create project')` Card assertion): + +```dart + expect(find.text('Badges'), findsWidgets); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/gallery && flutter test test/gallery_smoke_test.dart` +Expected: FAIL — `'Badges'` not found (no Badge section in the gallery yet). + +- [ ] **Step 3: Add a Badge showcase to `main.dart`** + +Add the import at the top of `apps/gallery/lib/main.dart` (after the card import): + +```dart +import 'components/ui/badge.dart'; +``` + +Then, inside the `home:` `Column(children: [...])`, after the Cards `SizedBox(width: 320, …)` block and before the closing `]`, insert: + +```dart + const SizedBox(height: 24), + Text('Badges').tw.textSize(20).weight(FwFontWeight.bold), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: const [ + Badge(child: Text('Primary')), + Badge(variant: BadgeVariant.secondary, child: Text('Secondary')), + Badge(variant: BadgeVariant.destructive, child: Text('Destructive')), + Badge(variant: BadgeVariant.outline, child: Text('Outline')), + Badge(leading: _Dot(), child: Text('Verified')), + ], + ), +``` + +> `Text('Badges').tw…` — `.tw` returns a non-const `FwStyled`, so that line is not `const` (matches the existing `Text('Cards').tw…` line). The existing `_Dot` helper already honors the ambient `IconTheme` (12px here via the Badge), so it reuses cleanly. + +- [ ] **Step 4: Run the smoke test + analyze** + +Run: `cd apps/gallery && flutter test test/gallery_smoke_test.dart` +Expected: PASS. +Run: `cd apps/gallery && flutter analyze --fatal-infos --fatal-warnings` +Expected: No issues. + +- [ ] **Step 5: Record the charter progress note (no-drift)** + +In `docs/superpowers/specs/2026-06-10-flutterbits-charter.md`, find the §8 "Sequencing update (2026-06-15)" paragraph (added for Card) and extend its final sentence so the shipped-primitives list includes Badge. Change: + +```markdown +`Card` (2026-06-15) is the first post-`Button` primitive and the canonical template for **compound, non-interactive** components (composed subcomponents, slot-based regions; see `apps/gallery/lib/components/ui/card.dart`). +``` + +to append a sentence: + +```markdown +`Badge` (2026-06-15) follows as a **variant-based, non-interactive single-box** primitive (`Button`'s variant discipline without its state machine; see `apps/gallery/lib/components/ui/badge.dart`). +``` + +> Verification (no edit): the flutterbits docs tab stays overview-only — `git grep -n "coming soon" apps/docs/content/docs/flutterbits` should still match, confirming Badge introduces no docs-tab drift (Button & Card are undocumented there too). Charter §3.2 already lists `Badge` in the primitives catalog, so no list edit is needed. + +- [ ] **Step 6: Commit** + +```bash +git add apps/gallery/lib/main.dart apps/gallery/test/gallery_smoke_test.dart docs/superpowers/specs/2026-06-10-flutterbits-charter.md +git commit -F +``` +Message: `feat(gallery): showcase Badge variants; smoke-test; charter progress note` + +--- + +### Task 7: Final verification + PR + CI golden re-baseline + +**Files:** none (verification + integration) + +- [ ] **Step 1: Full analyze (zero-warning bar)** + +Run: `cd apps/gallery && flutter analyze --fatal-infos --fatal-warnings` +Expected: `No issues found!` + +- [ ] **Step 2: Format check** + +Run: `dart format --line-length 100 --set-exit-if-changed apps/gallery/lib/components/ui/badge.dart apps/gallery/test/badge_behavior_test.dart apps/gallery/test/badge_golden_test.dart apps/gallery/lib/main.dart apps/gallery/test/gallery_smoke_test.dart` +Expected: no changes. If it reformats, re-run without `--set-exit-if-changed`, then re-commit. + +- [ ] **Step 3: Full gallery test suite** + +Run: `cd apps/gallery && flutter test` +Expected: all PASS (button + card + badge behavior, all golden suites, smoke). Goldens pass locally; CI will re-baseline. + +- [ ] **Step 4: Open the PR** + +```bash +git push -u origin feat/flutterbits-badge +gh pr create --title "feat(badge): Badge primitive (shadcn v4) + gallery showcase" --body-file +``` +PR body: summary, the design decisions (non-interactive variant pill; `primary`/reserved-word note; destructive→`destructiveForeground` + `dark:/60` dropped, both matching Button; all-variant transparent/border-token border; `w-fit` content sizing; non-interactive "wrap to make clickable"), the shadcn-v4 class mapping table, test coverage, and a note that goldens are provisional pending the CI Linux re-baseline. End with the Claude Code trailer. + +- [ ] **Step 5: Re-baseline goldens on CI Linux (the Button/Card precedent recipe)** + +Wait for the `gallery` CI job. If the golden step fails on the Linux renders (expected — Windows AA differs): + +```bash +gh run download -n gallery-golden-failures -D /tmp/badge-goldens +``` + +The `*_testImage.png` files are the authoritative Linux renders. Copy each over its baseline (strip the `_testImage` suffix): +- `badge_grid_light_testImage.png` → `apps/gallery/test/goldens/badge_grid_light.png` +- `badge_grid_dark_testImage.png` → `apps/gallery/test/goldens/badge_grid_dark.png` +- `badge_grid_rtl_testImage.png` → `apps/gallery/test/goldens/badge_grid_rtl.png` + +Eyeball-verify shape/layout (test font = boxes), then: + +```bash +git add apps/gallery/test/goldens/badge_grid_light.png apps/gallery/test/goldens/badge_grid_dark.png apps/gallery/test/goldens/badge_grid_rtl.png +git commit -F # "test(badge): re-baseline goldens on CI Linux (authoritative platform)" +git push +``` + +- [ ] **Step 6: Confirm green + merge** + +Run: `gh pr checks` until all green, then: +```bash +gh pr merge --merge --delete-branch +git checkout main && git pull +``` + +--- + +## Self-review (completed against the grounding) + +- **Spec coverage:** every shadcn v4 Badge class is mapped — base `rounded-full`/`border`/`px-2`/`py-0.5`/`text-xs`/`font-medium`/`gap-1`/`whitespace-nowrap`/`overflow-hidden`/`[&>svg]:size-3` (Task 1), all four variants' fills + foregrounds + borders (Task 1 `_palette`, locked in Task 2), icon slots + `[&>svg]:size-3` + semantics (Task 3). The `[a&]:hover:`/`focus-visible:`/`aria-invalid:` classes are explicitly out of scope (non-interactive) with the rationale recorded in the grounding + the class doc. +- **AGENTS.md rules:** semantic tokens only (the only literal is the §3.1-allowed transparent `Color(0x00000000)` for the transparent fill/border); directional layout (`px`/`py`, `FwRow` is directional, `Semantics`); `package:flutter/widgets.dart` only (no Material — passes the gallery arch-guard); `.tw` single-box styling, layout via `FwRow`; one component file; typed enum + exhaustive `switch` (no `default:`); golden coverage (every variant × brightness × RTL); rendered in `apps/gallery`. +- **Type consistency:** `BadgeVariant {primary,secondary,destructive,outline}`, `Badge(child, {variant, leading, trailing, semanticLabel})`, `_palette → ({Color bg, Color fg, Color border})` — used identically across implementation, tests, gallery, and goldens. `FwFontSize.xs.px`, `FwFontWeight.medium`, `.roundedFull`, `.clip()`, `.nowrap` all verified against the engine. +- **Placeholder scan:** none — every step has complete code or an exact command + expected output. The two "if the assertion fails" notes (Task 2 border type, Task 3 Row spacing) are explicit verify-then-assert fallbacks, not deferred work. +- **Deviation honesty:** destructive→`destructiveForeground` (not literal `text-white`) and dropped `dark:bg-destructive/60` are recorded as faithful-token deviations matching Button (visually identical in the stock theme, and they reskin) — §3.1/§12 honest, not silent. Non-interactive Badge + content-sizing are documented limitations with the compose-to-extend path, not silent gaps; neither lowers a capability bar (a clickable badge is composable today). From 6f75467a59fc6da7a61d80ab58ffaa85bc044bc1 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 10:52:44 +0200 Subject: [PATCH 10/10] chore(gitignore): ignore golden failure artifacts under any package test tree --- .gitignore | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 1d03cc7..5d669e3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,10 @@ pubspec.lock node_modules/ .next/ -# Golden-test failure artifacts (auto-written by `flutter test` on a golden diff) -test/**/failures/ +# Golden-test failure artifacts (auto-written by `flutter test` on a golden diff). +# Depth-agnostic so it covers every package/app test tree (apps/gallery, +# apps/example, packages/flutterwindcss), not just a root-level `test/`. +**/test/**/failures/ # Local Claude Code settings (per-machine) .claude/settings.local.json