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 diff --git a/apps/gallery/lib/components/ui/badge.dart b/apps/gallery/lib/components/ui/badge.dart new file mode 100644 index 0000000..c0b6240 --- /dev/null +++ b/apps/gallery/lib/components/ui/badge.dart @@ -0,0 +1,132 @@ +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). 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) { + 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!], + ); + } + // 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 = + 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) { + // `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/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/badge_behavior_test.dart b/apps/gallery/test/badge_behavior_test.dart new file mode 100644 index 0000000..1cf8a32 --- /dev/null +++ b/apps/gallery/test/badge_behavior_test.dart @@ -0,0 +1,214 @@ +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, + ); + }); + + 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 != 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( + 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 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.direction == Axis.horizontal && + w.mainAxisSize == MainAxisSize.min && + 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(); + }); + + 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)); + 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-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( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color == FwTokens.light.colors.primary, + ), + findsNothing, + ); + } + }); +} 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/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/apps/gallery/test/goldens/badge_grid_dark.png b/apps/gallery/test/goldens/badge_grid_dark.png new file mode 100644 index 0000000..fc2f6cb Binary files /dev/null and b/apps/gallery/test/goldens/badge_grid_dark.png differ diff --git a/apps/gallery/test/goldens/badge_grid_light.png b/apps/gallery/test/goldens/badge_grid_light.png new file mode 100644 index 0000000..d05cc2e Binary files /dev/null and b/apps/gallery/test/goldens/badge_grid_light.png differ 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 0000000..5916172 Binary files /dev/null and b/apps/gallery/test/goldens/badge_grid_rtl.png differ 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). 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. ---