diff --git a/AGENTS.md b/AGENTS.md
index 012c6d9..0dd42b1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -101,7 +101,7 @@ Semantic colors — the **full shadcn vocabulary** (32 tokens; this list is the
## 6. Authoring a flutterbits component
-Until the first component lands, follow the component checklist below and the engine design spec's component patterns (`docs/superpowers/specs`, §6). Once `registry/button.dart` exists it becomes the canonical template (it mirrors the reference `flutterbits/button.dart`). A component is **done** only when ALL of these hold:
+Follow the component checklist below and the engine design spec's component patterns (`docs/superpowers/specs`, §6). The canonical templates live in `apps/gallery/lib/components/ui/`: **`button.dart`** is the template for **interactive** components (own interaction-state machine via `FocusableActionDetector` + a tap detector, focus-visible `ring`, keyboard activation, `MinTapTarget`), and **`card.dart`** is the template for **compound, non-interactive** components (composed subcomponents, slot-based regions, theme-resolved tokens, no state machine). Mirror whichever matches the component you are authoring. (Both will be promoted into `registry/` with the registry/CLI work; until then the `apps/gallery` copies are authoritative.) A component is **done** only when ALL of these hold:
- [ ] Styled through `.tw`, using semantic tokens for every themeable value.
- [ ] Variants/sizes are typed enums with an exhaustive `switch` resolver.
@@ -221,7 +221,7 @@ Everything **not** in 11a or 11b is fair game and must not be refused on cost gr
- **Production-grade bar on every artifact (MUST).** Nothing ships as a provisional/"v1-minimal" anything. Code: complete (all cases, not just the easy one), guarded/asserted, idiomatic, no TODOs/stubs. Specs: technically sound and **capability-maximal** — they describe the best achievable design, not the cheapest. Docs: accurate and precise about what does and does not work *and why*. Tests: meaningful assertions, real edge cases, RTL, and goldens that would actually catch a regression. The architecture is locked day one and each shipped piece is final-quality; "we'll productionize later" does not exist here.
- **No silent scope reduction (MUST).** You may not narrow a feature (drop a case, defer a sub-feature, simplify an algorithm, sample instead of cover) without EITHER (a) a stated technical-impossibility reason with the mechanism, OR (b) explicit user sign-off on the trade-off. "It turned out to be large" is not a license to shrink it — it is a signal to plan it. If you must bound scope to make progress, `log`/say exactly what you dropped and why; silent truncation reads as "covered" when it wasn't.
- **Verdict before "won't" (MUST).** Before declaring anything a Non-Goal or limitation, write a one-paragraph feasibility verdict: the mechanism that *would* implement it, its rough cost, and the honest call. Attach it to the spec. Default to building; only the §11 bar excuses not building.
-- **Read before you edit.** Open the canonical sources (`lib/src/style/`, `lib/src/theme/`, `lib/src/tokens/`, and once it exists `registry/button.dart`) and match their patterns before writing new code.
+- **Read before you edit.** Open the canonical sources (`lib/src/style/`, `lib/src/theme/`, `lib/src/tokens/`, and the canonical components in `apps/gallery/lib/components/ui/` — `button.dart` for interactive, `card.dart` for compound/non-interactive) and match their patterns before writing new code.
- **Small, focused changes.** One component or one utility group per change. Don't refactor unrelated code in passing.
- **No new dependencies without justification.** Prefer the framework's widgets layer. Known sanctioned deps: `lucide_icons_flutter` (icons), `flutter_animate` (animation), and **`go_router`** (the routing engine the flutterbits structure layer *wraps* — not forks — declared as a `pubDep` of the structure components only; never a `flutterwindcss` dep). `flutter_svg` is sanctioned **by-demand** for blocks that render real SVG illustrations. Anything else needs a reason in the PR description. Deps are declared **per-component in the manifest `pubDeps`**, never globally: a copied `Button` drags in nothing; `add toast` pulls `flutter_animate`; `add layout` pulls `go_router`.
- **Don't invent APIs.** If unsure whether a Flutter symbol exists in the widgets layer, verify before using it. Do not assume Material symbols are available.
diff --git a/apps/gallery/lib/components/ui/card.dart b/apps/gallery/lib/components/ui/card.dart
new file mode 100644
index 0000000..ee54e47
--- /dev/null
+++ b/apps/gallery/lib/components/ui/card.dart
@@ -0,0 +1,198 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutterwindcss/flutterwindcss.dart';
+
+/// A Material-free, themeable card — shadcn parity. Copy-paste source you own.
+///
+/// **Intention-revealing & composed.** You build a card from sections you pass
+/// in [children] — typically [CardHeader], [CardContent], [CardFooter] — exactly
+/// like shadcn's `…`. Every value is a `flutterwindcss`
+/// semantic token (`card`, `cardForeground`, `border`), so a pasted theme reskins
+/// it.
+///
+/// **Layout (faithful to shadcn v4 `flex flex-col gap-6 rounded-xl border bg-card
+/// py-6 text-card-foreground shadow-sm`).** The card supplies only *vertical*
+/// padding (`py-6`) and a `gap-6` between sections; **horizontal** padding lives
+/// on each section ([CardHeader]/[CardContent]/[CardFooter] carry `px-6`). That
+/// split is deliberate: it lets a section bleed full-width (an image, or a
+/// bordered divider) while text stays inset.
+///
+/// **Block-level.** The card stretches to its parent's width (like a `
`), so
+/// it MUST be given a bounded width (place it in a constrained column, a
+/// `SizedBox`, or an `Expanded`). In an unbounded-width parent it throws a
+/// layout error.
+/// Children are stretched to the card's full width (block-level, like shadcn's
+/// `
` sections); a child that must shrink-wrap has to constrain itself
+/// (e.g. wrap in `Align`/`SizedBox`).
+///
+/// **Non-interactive** (shadcn's card is too). For a tappable card, wrap it
+/// (e.g. in a `GestureDetector`/`Button`); clickability is intentionally not
+/// baked in here.
+class Card extends StatelessWidget {
+ const Card({super.key, required this.children});
+
+ /// The card's sections, stacked vertically with a `gap-6` inside the card's
+ /// `py-6`. Typically [CardHeader], [CardContent], [CardFooter].
+ final List
children;
+
+ @override
+ Widget build(BuildContext context) {
+ final c = context.fw.colors;
+ return FwColumn(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ gap: 6,
+ children: children,
+ ).tw.py(6).bg(c.card).text(c.cardForeground).roundedXl.border(1, color: c.border).shadowSm;
+ }
+}
+
+/// The card's title — shadcn `leading-none font-semibold`. Inherits the card's
+/// `cardForeground` text color; pass a `Text` (or any widget) as [child].
+class CardTitle extends StatelessWidget {
+ const CardTitle(this.child, {super.key});
+
+ /// The title widget, typically a `Text`.
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) => child.tw.weight(FwFontWeight.semibold).leading(1); // leading-none
+}
+
+/// The card's supporting text — shadcn `text-sm text-muted-foreground`. The
+/// `mutedForeground` override wins over the card's inherited `cardForeground`
+/// for this subtree (nearest `DefaultTextStyle` wins).
+class CardDescription extends StatelessWidget {
+ const CardDescription(this.child, {super.key});
+
+ /// The description widget, typically a `Text`.
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) =>
+ child.tw.textSize(FwFontSize.sm.px).text(context.fw.colors.mutedForeground);
+}
+
+/// The card's top region — shadcn `grid … items-start gap-2 px-6`. Holds a
+/// [title], an optional [description], and an optional [action].
+///
+/// **Action layout (faithful, idiomatic).** shadcn flips to a two-column grid
+/// (`grid-cols-[1fr_auto]`) only when a `CardAction` sibling is present
+/// (`has-data-[slot=card-action]`). Flutter has no `has-[sibling]` selector, so
+/// the action is an explicit slot: with one, the title/description column takes
+/// the free space ([Expanded]) and the [action] sits at the top-end (shadcn
+/// `self-start justify-self-end`); without one, the title/description simply
+/// stack.
+///
+/// Set [bordered] to draw a bottom divider (shadcn `[.border-b]:pb-6` — the
+/// divider implies the extra bottom padding).
+class CardHeader extends StatelessWidget {
+ const CardHeader({
+ super.key,
+ required this.title,
+ this.description,
+ this.action,
+ this.bordered = false,
+ });
+
+ /// The card's title widget — typically [CardTitle].
+ final Widget title;
+
+ /// Optional supporting text widget — typically [CardDescription].
+ final Widget? description;
+
+ /// Optional action pinned to the header's top-end — typically [CardAction].
+ /// When present, triggers the two-column layout: title/description column
+ /// on the start side ([Expanded]), action on the end side.
+ final Widget? action;
+
+ /// When `true`, draws a bottom divider and adds `pb-6` — faithful to shadcn's
+ /// `[.border-b]:pb-6` conditional.
+ final bool bordered;
+
+ @override
+ Widget build(BuildContext context) {
+ final stack = FwColumn(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ gap: 2,
+ children: [title, if (description != null) description!],
+ );
+
+ final Widget content =
+ action == null
+ ? stack
+ : FwRow(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [Expanded(child: stack), action!],
+ );
+
+ var styled = content.tw.px(6);
+ if (bordered) {
+ styled = styled.pb(6).borderB(width: 1, color: context.fw.colors.border);
+ }
+ return styled;
+ }
+}
+
+/// An optional action in a [CardHeader] (e.g. a small button or link), pinned to
+/// the header's top-end — shadcn `self-start justify-self-end`. The alignment is
+/// supplied by [CardHeader]; this wrapper marks the role and keeps the call site
+/// readable. Currently a passthrough (no styling of its own), existing as a named
+/// slot for shadcn parity and as a seam for future styling (e.g. a default icon
+/// size or spacing offset).
+class CardAction extends StatelessWidget {
+ const CardAction(this.child, {super.key});
+
+ /// The action widget, e.g. a small `Button` or icon button.
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) => child;
+}
+
+/// The card's main content region — shadcn `px-6`. Holds arbitrary [child]
+/// content inside the card's horizontal inset.
+class CardContent extends StatelessWidget {
+ const CardContent(this.child, {super.key});
+
+ /// The content widget — any widget the caller provides.
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) => child.tw.px(6);
+}
+
+/// The card's bottom region — shadcn `flex items-center px-6`. Lays [children]
+/// out in a vertically-centered, **full-width** row (typically actions). Because
+/// the row fills the card width (like shadcn's block-level `flex` footer), a
+/// `Spacer`, an `Expanded`, or a `MainAxisAlignment` work to push actions apart
+/// or to the end (the canonical "Cancel … Save" footer). **No default gap**
+/// (faithful to shadcn): space adjacent children yourself (e.g. a `Spacer`).
+///
+/// Set [bordered] to draw a top divider (shadcn `[.border-t]:pt-6` — the divider
+/// implies the extra top padding).
+class CardFooter extends StatelessWidget {
+ const CardFooter({super.key, required this.children, this.bordered = false});
+
+ /// The footer's children, laid out in a centered, full-width row.
+ final List children;
+
+ /// When `true`, draws a top divider and adds `pt-6` — faithful to shadcn's
+ /// `[.border-t]:pt-6` conditional.
+ final bool bordered;
+
+ @override
+ Widget build(BuildContext context) {
+ // `MainAxisSize.max` fills the (stretched, bounded) footer box so a `Spacer`/
+ // `Expanded`/`MainAxisAlignment` works — matching shadcn's full-width footer.
+ var styled = FwRow(
+ mainAxisSize: MainAxisSize.max,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: children,
+ ).tw.px(6);
+ if (bordered) {
+ styled = styled.pt(6).borderT(width: 1, color: context.fw.colors.border);
+ }
+ return styled;
+ }
+}
diff --git a/apps/gallery/lib/main.dart b/apps/gallery/lib/main.dart
index 8c31f19..5c5460b 100644
--- a/apps/gallery/lib/main.dart
+++ b/apps/gallery/lib/main.dart
@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:flutterwindcss/flutterwindcss.dart';
import 'components/ui/button.dart';
+import 'components/ui/card.dart';
void main() => runApp(const GalleryApp());
@@ -77,6 +78,40 @@ class GalleryApp extends StatelessWidget {
],
),
),
+ const SizedBox(height: 24),
+ Text('Cards').tw.textSize(20).weight(FwFontWeight.bold),
+ const SizedBox(height: 12),
+ SizedBox(
+ width: 320,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: const [
+ Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Create project')),
+ description: CardDescription(
+ Text('Deploy your new project in one click.'),
+ ),
+ ),
+ CardContent(Text('Project configuration goes here.')),
+ CardFooter(children: [Text('Deploy')]),
+ ],
+ ),
+ SizedBox(height: 16),
+ Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Notifications')),
+ action: CardAction(Text('Manage')),
+ bordered: true,
+ ),
+ CardContent(Text('Email and push preferences.')),
+ ],
+ ),
+ ],
+ ),
+ ),
],
),
),
diff --git a/apps/gallery/test/card_behavior_test.dart b/apps/gallery/test/card_behavior_test.dart
new file mode 100644
index 0000000..11625c4
--- /dev/null
+++ b/apps/gallery/test/card_behavior_test.dart
@@ -0,0 +1,283 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutterwindcss/flutterwindcss.dart';
+import 'package:flutterbits_gallery/components/ui/card.dart';
+
+/// Theme + width frame. Card is block-level (stretches), so it needs a bounded
+/// width — a 320px box stands in for a real layout column.
+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: SizedBox(width: 320, child: child),
+ ),
+ ),
+ ),
+ ),
+);
+
+void main() {
+ testWidgets('Card paints the card token and stacks its children', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(children: [Text('alpha'), Text('beta')]),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('alpha'), findsOneWidget);
+ expect(find.text('beta'), findsOneWidget);
+ expect(
+ find.byWidgetPredicate(
+ (w) =>
+ w is DecoratedBox &&
+ w.decoration is BoxDecoration &&
+ (w.decoration as BoxDecoration).color == FwTokens.light.colors.card,
+ ),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets('CardTitle/CardDescription render their child text', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [CardTitle(Text('Title here')), CardDescription(Text('Subtitle here'))],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('Title here'), findsOneWidget);
+ expect(find.text('Subtitle here'), findsOneWidget);
+ });
+
+ testWidgets('CardHeader stacks title+description; no Expanded without an action', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Account')),
+ description: CardDescription(Text('Manage your account')),
+ ),
+ ],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('Account'), findsOneWidget);
+ expect(find.text('Manage your account'), findsOneWidget);
+ expect(find.byType(Expanded), findsNothing);
+ // bordered defaults to false → no directional bottom-divider (only the card's
+ // own uniform Border exists).
+ expect(
+ find.byWidgetPredicate((w) {
+ if (w is! DecoratedBox) return false;
+ final d = w.decoration;
+ if (d is! BoxDecoration) return false;
+ return d.border is BorderDirectional;
+ }),
+ findsNothing,
+ );
+ });
+
+ testWidgets('CardHeader with an action lays out two columns (Expanded + action)', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardHeader(title: CardTitle(Text('Account')), action: CardAction(Text('Edit'))),
+ ],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('Account'), findsOneWidget);
+ expect(find.text('Edit'), findsOneWidget);
+ expect(find.byType(Expanded), findsOneWidget);
+ });
+
+ testWidgets('bordered CardHeader adds a bottom-border divider', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(children: [CardHeader(title: CardTitle(Text('Account')), bordered: true)]),
+ ),
+ );
+ 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 b is BorderDirectional &&
+ b.bottom.width > 0 &&
+ b.bottom.color == FwTokens.light.colors.border;
+ }),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets('CardContent renders its child', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(children: [CardContent(Text('body text'))]),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('body text'), findsOneWidget);
+ });
+
+ testWidgets('CardFooter renders children in a row', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardFooter(children: [Text('Cancel'), Text('Save')]),
+ ],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('Cancel'), findsOneWidget);
+ expect(find.text('Save'), findsOneWidget);
+ // bordered defaults to false → no directional top-divider (only the card's
+ // own uniform Border exists).
+ expect(
+ find.byWidgetPredicate((w) {
+ if (w is! DecoratedBox) return false;
+ final d = w.decoration;
+ if (d is! BoxDecoration) return false;
+ return d.border is BorderDirectional;
+ }),
+ findsNothing,
+ );
+ });
+
+ testWidgets('bordered CardFooter adds a top-border divider', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardFooter(bordered: true, children: [Text('Save')]),
+ ],
+ ),
+ ),
+ );
+ 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 b is BorderDirectional &&
+ b.top.width > 0 &&
+ b.top.color == FwTokens.light.colors.border;
+ }),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets('renders under RTL with no overflow/exception', (t) async {
+ await t.binding.setSurfaceSize(const Size(400, 400));
+ addTearDown(() => t.binding.setSurfaceSize(null));
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.rtl,
+ const Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('عنوان')),
+ description: CardDescription(Text('وصف')),
+ action: CardAction(Text('تعديل')),
+ bordered: true,
+ ),
+ CardContent(Text('محتوى')),
+ CardFooter(bordered: true, children: [Text('حفظ')]),
+ ],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('عنوان'), findsOneWidget);
+ expect(find.text('تعديل'), findsOneWidget);
+ });
+
+ testWidgets('CardDescription resolves to mutedForeground; title keeps cardForeground', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Title here')),
+ description: CardDescription(Text('Subtitle here')),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ Color? colorOf(String text) {
+ final rich = t.widget(
+ find.descendant(of: find.text(text), matching: find.byType(RichText)),
+ );
+ return (rich.text as TextSpan).style?.color;
+ }
+
+ expect(colorOf('Subtitle here'), FwTokens.light.colors.mutedForeground);
+ expect(colorOf('Title here'), FwTokens.light.colors.cardForeground);
+ });
+
+ testWidgets('reskins with the active theme (dark card token)', (t) async {
+ await t.pumpWidget(
+ _frame(FwTokens.dark, TextDirection.ltr, const Card(children: [CardContent(Text('x'))])),
+ );
+ expect(t.takeException(), isNull);
+ // FwTokens.dark.colors.card == Color(0xFF171717) (neutral-900);
+ // FwTokens.light.colors.card == FwPalette.white — they differ, so the
+ // predicate distinguishes light vs dark without falling back to background.
+ expect(
+ find.byWidgetPredicate(
+ (w) =>
+ w is DecoratedBox &&
+ w.decoration is BoxDecoration &&
+ (w.decoration as BoxDecoration).color == FwTokens.dark.colors.card,
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.byWidgetPredicate(
+ (w) =>
+ w is DecoratedBox &&
+ w.decoration is BoxDecoration &&
+ (w.decoration as BoxDecoration).color == FwTokens.light.colors.card,
+ ),
+ findsNothing,
+ );
+ });
+}
diff --git a/apps/gallery/test/card_golden_test.dart b/apps/gallery/test/card_golden_test.dart
new file mode 100644
index 0000000..ba2b1b5
--- /dev/null
+++ b/apps/gallery/test/card_golden_test.dart
@@ -0,0 +1,95 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutterwindcss/flutterwindcss.dart';
+import 'package:flutterbits_gallery/components/ui/card.dart';
+
+/// Theme frame: FwTheme + Directionality + MediaQuery + background surface. The
+/// grid is wrapped in a RepaintBoundary so [matchesGoldenFile] captures a clean
+/// boundary. Cards are block-level (stretch), so each is width-constrained.
+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),
+ ),
+ ),
+ ),
+ ),
+);
+
+Widget _grid() => RepaintBoundary(
+ key: const ValueKey('card_grid'),
+ child: SizedBox(
+ width: 320,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: const [
+ // 1. Full card: header (title+description) + content + footer.
+ Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Create project')),
+ description: CardDescription(Text('Deploy your new project in one click.')),
+ ),
+ CardContent(Text('Project configuration and details go here.')),
+ CardFooter(children: [Text('Cancel '), Text('Deploy')]),
+ ],
+ ),
+ SizedBox(height: 16),
+ // 2. Header with an action + bordered header & footer dividers.
+ Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Notifications')),
+ description: CardDescription(Text('Choose what you get notified about.')),
+ action: CardAction(Text('Manage')),
+ bordered: true,
+ ),
+ CardContent(Text('Email, push, and SMS preferences.')),
+ CardFooter(bordered: true, children: [Text('Save preferences')]),
+ ],
+ ),
+ ],
+ ),
+ ),
+);
+
+void main() {
+ const surfaceSize = Size(420, 700);
+
+ testWidgets('card 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('card_grid')),
+ matchesGoldenFile('goldens/card_grid_light.png'),
+ );
+ });
+
+ testWidgets('card 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('card_grid')),
+ matchesGoldenFile('goldens/card_grid_dark.png'),
+ );
+ });
+
+ testWidgets('card grid — light RTL (directional padding/dividers 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('card_grid')),
+ matchesGoldenFile('goldens/card_grid_rtl.png'),
+ );
+ });
+}
diff --git a/apps/gallery/test/gallery_smoke_test.dart b/apps/gallery/test/gallery_smoke_test.dart
index 6f011e0..2a5d43f 100644
--- a/apps/gallery/test/gallery_smoke_test.dart
+++ b/apps/gallery/test/gallery_smoke_test.dart
@@ -10,5 +10,6 @@ void main() {
await t.pumpAndSettle();
expect(t.takeException(), isNull);
expect(find.text('primary'), findsWidgets);
+ expect(find.text('Create project'), findsWidgets);
});
}
diff --git a/apps/gallery/test/goldens/card_grid_dark.png b/apps/gallery/test/goldens/card_grid_dark.png
new file mode 100644
index 0000000..2a0fd20
Binary files /dev/null and b/apps/gallery/test/goldens/card_grid_dark.png differ
diff --git a/apps/gallery/test/goldens/card_grid_light.png b/apps/gallery/test/goldens/card_grid_light.png
new file mode 100644
index 0000000..de74e78
Binary files /dev/null and b/apps/gallery/test/goldens/card_grid_light.png differ
diff --git a/apps/gallery/test/goldens/card_grid_rtl.png b/apps/gallery/test/goldens/card_grid_rtl.png
new file mode 100644
index 0000000..9dd7074
Binary files /dev/null and b/apps/gallery/test/goldens/card_grid_rtl.png differ
diff --git a/docs/superpowers/plans/2026-06-15-flutterbits-card.md b/docs/superpowers/plans/2026-06-15-flutterbits-card.md
new file mode 100644
index 0000000..6cd3e1e
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-15-flutterbits-card.md
@@ -0,0 +1,1009 @@
+# Card 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 `Card` — the second flutterbits primitive and the canonical template for *compound, non-interactive* components — as Material-free, themeable, shadcn-v4-faithful copy-paste source in `apps/gallery`, with behavior + golden tests and CI gating.
+
+**Architecture:** A `Card` is a `flex-col gap-6 py-6` styled column of sections; horizontal padding (`px-6`) lives on the *sections* (`CardHeader`/`CardContent`/`CardFooter`), not the card, so full-bleed children/dividers are possible — faithful to shadcn v4. Every value is a `flutterwindcss` semantic token (`card`, `cardForeground`, `border`, `mutedForeground`) resolved through `.tw`, so a theme swap reskins it. Subcomponents are composed (you pass them in `Card(children: […])`), mirroring shadcn's compound-component DX. The one principled deviation: `CardHeader` exposes explicit `title`/`description`/`action` **slots** rather than generic children, because shadcn's `has-data-[slot=card-action]:grid-cols-[1fr_auto]` (sibling-presence detection that flips the grid to two columns) has **no clean Flutter analog** — explicit slots reproduce the exact title|action layout faithfully and read intention-revealingly (consistent with the structure layer's slot philosophy). This raises, not lowers, the capability bar.
+
+**Tech Stack:** Dart / Flutter (`package:flutter/widgets.dart` only — no Material), `flutterwindcss` engine (`.tw`, `context.fw`, `FwColumn`/`FwRow`), `flutter_test` goldens (CI Linux authoritative).
+
+---
+
+## Grounding (read before starting)
+
+**Authoritative shadcn v4 Card classes** (from `ui.shadcn.com/r/styles/new-york-v4/card.json`, verbatim):
+
+| Part | className |
+|---|---|
+| `Card` (root) | `flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm` |
+| `CardHeader` | `@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6` |
+| `CardTitle` | `leading-none font-semibold` |
+| `CardDescription` | `text-sm text-muted-foreground` |
+| `CardAction` | `col-start-2 row-span-2 row-start-1 self-start justify-self-end` |
+| `CardContent` | `px-6` |
+| `CardFooter` | `flex items-center px-6 [.border-t]:pt-6` |
+
+**Engine API facts verified against `packages/flutterwindcss/lib/src/style/fw_style_ops.dart`:**
+- `.text(Color)` sets `foreground`, which flows through `DefaultTextStyle.merge` → **cascades to all descendant text** (so `Card.text(cardForeground)` reaches nested title/content; a nested `CardDescription.text(mutedForeground)` overrides it locally — nearest wins).
+- Spacing units are utility units = ×4 logical px (`gap-6` → `gap: 6` = 24px; `px-6` → `.px(6)`; `pb-6` → `.pb(6)`; `pt-6` → `.pt(6)`). Setters: `.p/.px/.py/.ps/.pe/.pt/.pb` (all directional; `_mergePad` merges per-edge so `.px(6).pt(6)` keeps both).
+- `.roundedXl` (theme-resolved `FwRadiusStep.xl` ⇒ `rounded-xl`), `.shadowSm` (theme-resolved `FwShadowStep.sm` ⇒ `shadow-sm`).
+- `.border(1, color: …)` (all-side), `.borderB(width: 1, color: …)` / `.borderT(width: 1, color: …)` (single-edge; border paints *outside* padding, so a bordered section's divider spans the full section width below its `pb-6`).
+- `.weight(FwFontWeight.semibold)` (semibold = 600; `FwFontWeight.*` are `int` consts), `.textSize(FwFontSize.sm.px)` (= 14), `.leading(1)` (line-height multiple ⇒ `leading-none`).
+- `FwColumn`/`FwRow` accept `gap`, `crossAxisAlignment`, `mainAxisSize` (passthrough to `Column`/`Row` + `spacing`).
+
+**Canonical patterns to mirror** (`apps/gallery/lib/components/ui/button.dart`): file header doc-comment explaining intention + theming + limitations; semantic tokens only; directional layout; one component per file; analyzer-clean with `--fatal-infos --fatal-warnings`; `dart format` 100-col.
+
+**Golden harness to mirror** (`apps/gallery/test/button_golden_test.dart`): `FwTheme(tokens:) → Directionality → MediaQuery → ColoredBox(background) → Align(topStart) → 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 artifact (see Task 8 re-baseline recipe).
+
+**Design decisions locked for this plan (rationale in the Architecture section):**
+1. `Card(children: List)` — compose subcomponents (shadcn compound-component DX).
+2. `CardHeader` uses explicit `title`/`description`/`action` slots (faithful action-grid without `has-[sibling]`); `CardTitle`/`CardDescription` remain standalone styled wrappers usable in those slots or anywhere.
+3. `Card` is **non-interactive** (shadcn's is too). A clickable card is composed by wrapping the card (e.g. in a `Button`/`GestureDetector`); explicitly out of scope, documented in the class doc.
+4. `Card` is **block-level**: its column uses `crossAxisAlignment: stretch`, so it fills — and **requires** — a bounded-width parent (like a ``). Documented; the gallery + goldens wrap cards in a width-constrained parent.
+5. `CardHeader`/`CardFooter` take a `bordered` flag: `true` adds the divider edge (`borderB`/`borderT`) **and** the shadcn-coupled padding (`pb-6`/`pt-6`), matching `[.border-b]:pb-6` / `[.border-t]:pt-6`.
+
+**No-drift scope:** This plan reorders the charter — primitives are being built out before the structure layer. Task 9 updates `docs/superpowers/specs/2026-06-10-flutterbits-charter.md` §8 accordingly and adds `card.dart` as the canonical compound-component template note in `AGENTS.md` §6.
+
+**Deferred (recorded, not silently dropped):** registry promotion (`registry/card.dart` + manifest + `tooling/build_registry.dart`) stays with the registry/CLI plan, exactly as for `button` — Card lives at `apps/gallery/lib/components/ui/card.dart` for now. Per-component docs pages remain a future additive pass (flutterbits docs tab is overview-only by the 2026-06-09 decision; Button is undocumented there too — so Card introduces no docs drift).
+
+---
+
+## File structure
+
+- Create: `apps/gallery/lib/components/ui/card.dart` — `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter` (one file; they are one cohesive copy-paste unit, mirroring shadcn's single `card.tsx`).
+- Create: `apps/gallery/test/card_behavior_test.dart` — structure/layout/theming assertions.
+- Create: `apps/gallery/test/card_golden_test.dart` — golden grid (light LTR / dark LTR / light RTL).
+- Create: `apps/gallery/test/goldens/card_grid_light.png`, `card_grid_dark.png`, `card_grid_rtl.png` (provisional locally; re-baselined from CI artifact).
+- Modify: `apps/gallery/lib/main.dart` — add a Card showcase section so CI compiles the component in the app.
+- Modify: `apps/gallery/test/gallery_smoke_test.dart` — assert a Card example renders.
+- Modify: `docs/superpowers/specs/2026-06-10-flutterbits-charter.md` (§8 sequencing) and `AGENTS.md` (§6 canonical-template note).
+
+---
+
+### Task 1: `Card` root
+
+**Files:**
+- Create: `apps/gallery/lib/components/ui/card.dart`
+- Test: `apps/gallery/test/card_behavior_test.dart`
+
+- [ ] **Step 1: Write the failing test**
+
+```dart
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutterwindcss/flutterwindcss.dart';
+import 'package:flutterbits_gallery/components/ui/card.dart';
+
+/// Theme + width frame. Card is block-level (stretches), so it needs a bounded
+/// width — a 320px box stands in for a real layout column.
+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: SizedBox(width: 320, child: child),
+ ),
+ ),
+ ),
+ ),
+);
+
+void main() {
+ testWidgets('Card paints the card token and stacks its children', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(children: [Text('alpha'), Text('beta')]),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('alpha'), findsOneWidget);
+ expect(find.text('beta'), findsOneWidget);
+ // The card fill uses the `card` semantic token (theme reskin proof).
+ expect(
+ find.byWidgetPredicate(
+ (w) =>
+ w is DecoratedBox &&
+ w.decoration is BoxDecoration &&
+ (w.decoration as BoxDecoration).color == FwTokens.light.colors.card,
+ ),
+ findsOneWidget,
+ );
+ });
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: FAIL — `Card` is undefined (`card.dart` does not exist / no `Card`).
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `apps/gallery/lib/components/ui/card.dart`:
+
+```dart
+import 'package:flutter/widgets.dart';
+import 'package:flutterwindcss/flutterwindcss.dart';
+
+/// A Material-free, themeable card — shadcn parity. Copy-paste source you own.
+///
+/// **Intention-revealing & composed.** You build a card from sections you pass
+/// in [children] — typically [CardHeader], [CardContent], [CardFooter] — exactly
+/// like shadcn's `
…`. Every value is a `flutterwindcss`
+/// semantic token (`card`, `cardForeground`, `border`), so a pasted theme reskins
+/// it.
+///
+/// **Layout (faithful to shadcn v4 `flex flex-col gap-6 rounded-xl border bg-card
+/// py-6 text-card-foreground shadow-sm`).** The card supplies only *vertical*
+/// padding (`py-6`) and a `gap-6` between sections; **horizontal** padding lives
+/// on each section ([CardHeader]/[CardContent]/[CardFooter] carry `px-6`). That
+/// split is deliberate: it lets a section bleed full-width (an image, or a
+/// bordered divider) while text stays inset.
+///
+/// **Block-level.** The card stretches to its parent's width (like a `
`), so
+/// it MUST be given a bounded width (place it in a constrained column, a
+/// `SizedBox`, or an `Expanded`). In an unbounded-width parent it will assert.
+///
+/// **Non-interactive** (shadcn's card is too). For a tappable card, wrap it
+/// (e.g. in a `GestureDetector`/`Button`); clickability is intentionally not
+/// baked in here.
+class Card extends StatelessWidget {
+ const Card({super.key, required this.children});
+
+ /// The card's sections, stacked vertically with a `gap-6` inside the card's
+ /// `py-6`. Typically [CardHeader], [CardContent], [CardFooter].
+ final List
children;
+
+ @override
+ Widget build(BuildContext context) {
+ final c = context.fw.colors;
+ return FwColumn(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ gap: 6,
+ children: children,
+ ).tw.py(6).bg(c.card).text(c.cardForeground).roundedXl.border(1, color: c.border).shadowSm;
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/gallery/lib/components/ui/card.dart apps/gallery/test/card_behavior_test.dart
+git commit -F
+```
+Message: `feat(card): Card root — themed, block-level, gap-6 section column`
+
+---
+
+### Task 2: `CardTitle` + `CardDescription`
+
+**Files:**
+- Modify: `apps/gallery/lib/components/ui/card.dart`
+- Test: `apps/gallery/test/card_behavior_test.dart`
+
+- [ ] **Step 1: Write the failing test** (append inside `main()`)
+
+```dart
+ testWidgets('CardTitle/CardDescription render their child text', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardTitle(Text('Title here')),
+ CardDescription(Text('Subtitle here')),
+ ],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('Title here'), findsOneWidget);
+ expect(find.text('Subtitle here'), findsOneWidget);
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: FAIL — `CardTitle`/`CardDescription` undefined.
+
+- [ ] **Step 3: Write minimal implementation** (append to `card.dart`)
+
+```dart
+/// The card's title — shadcn `leading-none font-semibold`. Inherits the card's
+/// `cardForeground` text color; pass a `Text` (or any widget) as [child].
+class CardTitle extends StatelessWidget {
+ const CardTitle(this.child, {super.key});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) => child.tw.weight(FwFontWeight.semibold).leading(1);
+}
+
+/// The card's supporting text — shadcn `text-sm text-muted-foreground`. The
+/// `mutedForeground` override wins over the card's inherited `cardForeground`
+/// for this subtree (nearest `DefaultTextStyle` wins).
+class CardDescription extends StatelessWidget {
+ const CardDescription(this.child, {super.key});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) =>
+ child.tw.textSize(FwFontSize.sm.px).text(context.fw.colors.mutedForeground);
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/gallery/lib/components/ui/card.dart apps/gallery/test/card_behavior_test.dart
+git commit -F
+```
+Message: `feat(card): CardTitle (semibold/leading-none) + CardDescription (sm/muted)`
+
+---
+
+### Task 3: `CardHeader` + `CardAction` (slots, action grid, bordered divider)
+
+**Files:**
+- Modify: `apps/gallery/lib/components/ui/card.dart`
+- Test: `apps/gallery/test/card_behavior_test.dart`
+
+- [ ] **Step 1: Write the failing test** (append inside `main()`)
+
+```dart
+ testWidgets('CardHeader stacks title+description; no Expanded without an action', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Account')),
+ description: CardDescription(Text('Manage your account')),
+ ),
+ ],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('Account'), findsOneWidget);
+ expect(find.text('Manage your account'), findsOneWidget);
+ // No action → simple stack, no two-column Row/Expanded.
+ expect(find.byType(Expanded), findsNothing);
+ });
+
+ testWidgets('CardHeader with an action lays out two columns (Expanded + action)', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Account')),
+ action: CardAction(Text('Edit')),
+ ),
+ ],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('Account'), findsOneWidget);
+ expect(find.text('Edit'), findsOneWidget);
+ // Action present → title column is wrapped in an Expanded beside the action.
+ expect(find.byType(Expanded), findsOneWidget);
+ });
+
+ testWidgets('bordered CardHeader adds a bottom-border divider', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardHeader(title: CardTitle(Text('Account')), bordered: true),
+ ],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ // A box whose decoration carries a non-zero bottom border in the `border`
+ // token (the divider).
+ 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 b != null &&
+ b.bottom.width > 0 &&
+ b.bottom.color == FwTokens.light.colors.border;
+ }),
+ findsWidgets,
+ );
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: FAIL — `CardHeader`/`CardAction` undefined.
+
+- [ ] **Step 3: Write minimal implementation** (append to `card.dart`)
+
+```dart
+/// The card's top region — shadcn `grid … items-start gap-2 px-6`. Holds a
+/// [title], an optional [description], and an optional [action].
+///
+/// **Action layout (faithful, idiomatic).** shadcn flips to a two-column grid
+/// (`grid-cols-[1fr_auto]`) only when a `CardAction` sibling is present
+/// (`has-data-[slot=card-action]`). Flutter has no `has-[sibling]` selector, so
+/// the action is an explicit slot: with one, the title/description column takes
+/// the free space ([Expanded]) and the [action] sits at the top-end (shadcn
+/// `self-start justify-self-end`); without one, the title/description simply
+/// stack.
+///
+/// Set [bordered] to draw a bottom divider (shadcn `[.border-b]:pb-6` — the
+/// divider implies the extra bottom padding).
+class CardHeader extends StatelessWidget {
+ const CardHeader({
+ super.key,
+ required this.title,
+ this.description,
+ this.action,
+ this.bordered = false,
+ });
+
+ final Widget title;
+ final Widget? description;
+ final Widget? action;
+ final bool bordered;
+
+ @override
+ Widget build(BuildContext context) {
+ final stack = FwColumn(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ gap: 2,
+ children: [title, if (description != null) description!],
+ );
+
+ final Widget content =
+ action == null
+ ? stack
+ : FwRow(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [Expanded(child: stack), action!],
+ );
+
+ var styled = content.tw.px(6);
+ if (bordered) {
+ styled = styled.pb(6).borderB(width: 1, color: context.fw.colors.border);
+ }
+ return styled;
+ }
+}
+
+/// An optional action in a [CardHeader] (e.g. a small button or link), pinned to
+/// the header's top-end — shadcn `self-start justify-self-end`. The alignment is
+/// supplied by [CardHeader]; this wrapper marks the role and keeps the call site
+/// readable.
+class CardAction extends StatelessWidget {
+ const CardAction(this.child, {super.key});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) => child;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/gallery/lib/components/ui/card.dart apps/gallery/test/card_behavior_test.dart
+git commit -F
+```
+Message: `feat(card): CardHeader slots (title/description/action) + bordered divider`
+
+---
+
+### Task 4: `CardContent`
+
+**Files:**
+- Modify: `apps/gallery/lib/components/ui/card.dart`
+- Test: `apps/gallery/test/card_behavior_test.dart`
+
+- [ ] **Step 1: Write the failing test** (append inside `main()`)
+
+```dart
+ testWidgets('CardContent renders its child', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(children: [CardContent(Text('body text'))]),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('body text'), findsOneWidget);
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: FAIL — `CardContent` undefined.
+
+- [ ] **Step 3: Write minimal implementation** (append to `card.dart`)
+
+```dart
+/// The card's main content region — shadcn `px-6`. Holds arbitrary [child]
+/// content inside the card's horizontal inset.
+class CardContent extends StatelessWidget {
+ const CardContent(this.child, {super.key});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) => child.tw.px(6);
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/gallery/lib/components/ui/card.dart apps/gallery/test/card_behavior_test.dart
+git commit -F
+```
+Message: `feat(card): CardContent (px-6 content region)`
+
+---
+
+### Task 5: `CardFooter` (+ bordered top divider)
+
+**Files:**
+- Modify: `apps/gallery/lib/components/ui/card.dart`
+- Test: `apps/gallery/test/card_behavior_test.dart`
+
+- [ ] **Step 1: Write the failing test** (append inside `main()`)
+
+```dart
+ testWidgets('CardFooter renders children in a row', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardFooter(children: [Text('Cancel'), Text('Save')]),
+ ],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('Cancel'), findsOneWidget);
+ expect(find.text('Save'), findsOneWidget);
+ });
+
+ testWidgets('bordered CardFooter adds a top-border divider', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.ltr,
+ const Card(
+ children: [
+ CardFooter(bordered: true, children: [Text('Save')]),
+ ],
+ ),
+ ),
+ );
+ 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 b != null && b.top.width > 0 && b.top.color == FwTokens.light.colors.border;
+ }),
+ findsWidgets,
+ );
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: FAIL — `CardFooter` undefined.
+
+- [ ] **Step 3: Write minimal implementation** (append to `card.dart`)
+
+```dart
+/// The card's bottom region — shadcn `flex items-center px-6`. Lays [children]
+/// out in a vertically-centered row (typically actions). **No default gap**
+/// (faithful to shadcn) — space the children yourself (e.g. an `FwRow` gap, or
+/// a `Spacer`).
+///
+/// Set [bordered] to draw a top divider (shadcn `[.border-t]:pt-6` — the divider
+/// implies the extra top padding).
+class CardFooter extends StatelessWidget {
+ const CardFooter({super.key, required this.children, this.bordered = false});
+
+ final List children;
+ final bool bordered;
+
+ @override
+ Widget build(BuildContext context) {
+ var styled =
+ FwRow(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: children,
+ ).tw.px(6);
+ if (bordered) {
+ styled = styled.pt(6).borderT(width: 1, color: context.fw.colors.border);
+ }
+ return styled;
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/gallery/lib/components/ui/card.dart apps/gallery/test/card_behavior_test.dart
+git commit -F
+```
+Message: `feat(card): CardFooter (items-center row) + bordered top divider`
+
+---
+
+### Task 6: RTL + theming reskin behavior tests
+
+**Files:**
+- Test: `apps/gallery/test/card_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, 400));
+ addTearDown(() => t.binding.setSurfaceSize(null));
+ await t.pumpWidget(
+ _frame(
+ FwTokens.light,
+ TextDirection.rtl,
+ const Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('عنوان')),
+ description: CardDescription(Text('وصف')),
+ action: CardAction(Text('تعديل')),
+ bordered: true,
+ ),
+ CardContent(Text('محتوى')),
+ CardFooter(bordered: true, children: [Text('حفظ')]),
+ ],
+ ),
+ ),
+ );
+ expect(t.takeException(), isNull);
+ expect(find.text('عنوان'), findsOneWidget);
+ expect(find.text('تعديل'), findsOneWidget);
+ });
+
+ testWidgets('reskins with the active theme (dark card token)', (t) async {
+ await t.pumpWidget(
+ _frame(
+ FwTokens.dark,
+ TextDirection.ltr,
+ const Card(children: [CardContent(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.card,
+ ),
+ findsOneWidget,
+ );
+ // The light card token must NOT appear (proves it actually reskinned).
+ expect(
+ find.byWidgetPredicate(
+ (w) =>
+ w is DecoratedBox &&
+ w.decoration is BoxDecoration &&
+ (w.decoration as BoxDecoration).color == FwTokens.light.colors.card,
+ ),
+ findsNothing,
+ );
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails or passes**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: PASS (the implementation already supports these; this task locks the behavior with regression tests). If the RTL test reports a RenderFlex overflow, that is a real bug — fix the layout (do not loosen the assertion).
+
+- [ ] **Step 3: (only if a test failed) fix the implementation**
+
+If the dark-token test fails because `FwTokens.dark.colors.card == FwTokens.light.colors.card` for the stock theme, switch the assertion to a token that differs between brightnesses (`background`) — but verify the stock `FwTokens` card values first with a one-off `expect(FwTokens.light.colors.card == FwTokens.dark.colors.card, isFalse)` and keep `card` if they differ.
+
+- [ ] **Step 4: Run the full behavior suite**
+
+Run: `cd apps/gallery && flutter test test/card_behavior_test.dart`
+Expected: PASS (all cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/gallery/test/card_behavior_test.dart
+git commit -F
+```
+Message: `test(card): RTL composition + theme-reskin regression coverage`
+
+---
+
+### Task 7: Golden grid (light / dark / RTL)
+
+**Files:**
+- Create: `apps/gallery/test/card_golden_test.dart`
+- Create (provisional, then re-baselined on CI): `apps/gallery/test/goldens/card_grid_{light,dark,rtl}.png`
+
+- [ ] **Step 1: Write the golden test**
+
+```dart
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutterwindcss/flutterwindcss.dart';
+import 'package:flutterbits_gallery/components/ui/card.dart';
+
+/// Theme frame: FwTheme + Directionality + MediaQuery + background surface. The
+/// grid is wrapped in a RepaintBoundary so [matchesGoldenFile] captures a clean
+/// boundary. Cards are block-level (stretch), so each is width-constrained.
+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),
+ ),
+ ),
+ ),
+ ),
+);
+
+Widget _grid() => RepaintBoundary(
+ key: const ValueKey('card_grid'),
+ child: SizedBox(
+ width: 320,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: const [
+ // 1. Full card: header (title+description) + content + footer.
+ Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Create project')),
+ description: CardDescription(Text('Deploy your new project in one click.')),
+ ),
+ CardContent(Text('Project configuration and details go here.')),
+ CardFooter(children: [Text('Cancel '), Text('Deploy')]),
+ ],
+ ),
+ SizedBox(height: 16),
+ // 2. Header with an action + bordered header & footer dividers.
+ Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Notifications')),
+ description: CardDescription(Text('Choose what you get notified about.')),
+ action: CardAction(Text('Manage')),
+ bordered: true,
+ ),
+ CardContent(Text('Email, push, and SMS preferences.')),
+ CardFooter(bordered: true, children: [Text('Save preferences')]),
+ ],
+ ),
+ ],
+ ),
+ ),
+);
+
+void main() {
+ const surfaceSize = Size(420, 700);
+
+ testWidgets('card 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('card_grid')),
+ matchesGoldenFile('goldens/card_grid_light.png'),
+ );
+ });
+
+ testWidgets('card 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('card_grid')),
+ matchesGoldenFile('goldens/card_grid_dark.png'),
+ );
+ });
+
+ testWidgets('card grid — light RTL (directional padding/dividers 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('card_grid')),
+ matchesGoldenFile('goldens/card_grid_rtl.png'),
+ );
+ });
+}
+```
+
+- [ ] **Step 2: Run to verify it fails (no goldens yet)**
+
+Run: `cd apps/gallery && flutter test test/card_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/card_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: rounded card, dividers in the bordered card, action at the end, RTL mirrors the inset/dividers). Provisional only — CI Linux is authoritative (next step).
+
+- [ ] **Step 4: Run to verify the provisional goldens pass locally**
+
+Run: `cd apps/gallery && flutter test test/card_golden_test.dart`
+Expected: PASS (locally).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/gallery/test/card_golden_test.dart apps/gallery/test/goldens/card_grid_light.png apps/gallery/test/goldens/card_grid_dark.png apps/gallery/test/goldens/card_grid_rtl.png
+git commit -F
+```
+Message: `test(card): golden grid (light/dark/RTL) — provisional, CI Linux authoritative`
+
+> Re-baselining on CI happens in Task 8 after the PR is open (Windows goldens are known to differ from CI Linux — Button precedent).
+
+---
+
+### Task 8: Gallery integration + smoke test
+
+**Files:**
+- Modify: `apps/gallery/lib/main.dart`
+- Modify: `apps/gallery/test/gallery_smoke_test.dart`
+
+- [ ] **Step 1: Update the smoke test (failing) to expect a Card example**
+
+In `apps/gallery/test/gallery_smoke_test.dart`, add an assertion after the existing `find.text('primary')` check:
+
+```dart
+ expect(find.text('Create project'), findsWidgets);
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd apps/gallery && flutter test test/gallery_smoke_test.dart`
+Expected: FAIL — `'Create project'` not found (no Card in the gallery yet).
+
+- [ ] **Step 3: Add a Card showcase to `main.dart`**
+
+In `apps/gallery/lib/main.dart`, import the card and add a Card section to the showcase. Replace the `home:` `Column`'s child list so it shows the existing button rows **and** a cards section below them. Concretely, add this import at the top:
+
+```dart
+import 'components/ui/card.dart';
+```
+
+and, inside the `Column(children: [...])` in `home:` (after the existing button `Padding` blocks, before the closing `]`), insert:
+
+```dart
+ const SizedBox(height: 24),
+ const Text('Cards').tw.textSize(20).weight(FwFontWeight.bold),
+ const SizedBox(height: 12),
+ SizedBox(
+ width: 320,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: const [
+ Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Create project')),
+ description: CardDescription(
+ Text('Deploy your new project in one click.'),
+ ),
+ ),
+ CardContent(Text('Project configuration goes here.')),
+ CardFooter(children: [Text('Deploy')]),
+ ],
+ ),
+ SizedBox(height: 16),
+ Card(
+ children: [
+ CardHeader(
+ title: CardTitle(Text('Notifications')),
+ action: CardAction(Text('Manage')),
+ bordered: true,
+ ),
+ CardContent(Text('Email and push preferences.')),
+ ],
+ ),
+ ],
+ ),
+ ),
+```
+
+> Note: `const Text('Cards').tw…` — `.tw` returns a non-const `FwStyled`, so drop `const` on that one line if the analyzer flags it (`Text('Cards').tw.textSize(20).weight(FwFontWeight.bold)`).
+
+- [ ] **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: Commit**
+
+```bash
+git add apps/gallery/lib/main.dart apps/gallery/test/gallery_smoke_test.dart
+git commit -F
+```
+Message: `feat(gallery): showcase Card examples; smoke-test the card`
+
+---
+
+### Task 9: No-drift — charter §8 reorder + AGENTS §6 canonical-template note
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-06-10-flutterbits-charter.md`
+- Modify: `AGENTS.md`
+
+- [ ] **Step 1: Update charter §8 sequencing**
+
+In `docs/superpowers/specs/2026-06-10-flutterbits-charter.md` §8, after the numbered decomposition list, add a paragraph recording the reorder (do not delete the existing list — it stays the umbrella plan):
+
+```markdown
+> **Sequencing update (2026-06-15):** after `Button` proved the component-authoring stack end-to-end (PR #45–#47), the **primitives catalog (§3.2) is being built out before the structure layer (§2 above)** — lower-risk, reuses the proven pattern, and several structure pieces (e.g. `ThemeToggle` → `Switch`) depend on primitives anyway. `Card` is the first post-`Button` primitive and the canonical template for *compound, non-interactive* components. The structure-and-routing spec remains next-in-line as an epic; this is a sequencing change, not a scope change.
+```
+
+- [ ] **Step 2: Add the canonical-template note to AGENTS.md §6**
+
+In `AGENTS.md` §6, find the sentence about `registry/button.dart` becoming the canonical template and append a sibling note (keep the existing text):
+
+```markdown
+> `apps/gallery/lib/components/ui/card.dart` is the canonical template for **compound, non-interactive** components (composed subcomponents, slot-based regions, theme-resolved tokens, no interaction state machine), complementing `button.dart` (the canonical **interactive** component). Mirror whichever matches the component you are authoring.
+```
+
+- [ ] **Step 3: Verify no other doc falsified**
+
+Run: `git grep -n "coming soon" apps/docs/content/docs/flutterbits` — confirm the flutterbits docs tab is still overview-only (Button is undocumented there too, so Card adds no docs drift). No edit needed; this is a verification step.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/superpowers/specs/2026-06-10-flutterbits-charter.md AGENTS.md
+git commit -F
+```
+Message: `docs: record primitives-before-structure reorder + card canonical template (no-drift)`
+
+---
+
+### Task 10: Final verification + PR
+
+**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/card.dart apps/gallery/test/card_behavior_test.dart apps/gallery/test/card_golden_test.dart apps/gallery/lib/main.dart apps/gallery/test/gallery_smoke_test.dart`
+Expected: no changes (already formatted). 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 behavior, both golden suites, smoke). Goldens pass locally; CI will re-baseline.
+
+- [ ] **Step 4: Open the PR**
+
+```bash
+git push -u origin feat/flutterbits-card
+gh pr create --title "feat(card): Card primitive (shadcn v4) + gallery showcase" --body-file
+```
+PR body: summary, the design decisions (composition; header slots vs `has-[sibling]`; block-level width; bordered dividers), 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 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/card-goldens
+```
+
+The `*_testImage.png` files are the authoritative Linux renders. Copy each over its baseline (strip the `_testImage` suffix):
+- `card_grid_light_testImage.png` → `apps/gallery/test/goldens/card_grid_light.png`
+- `card_grid_dark_testImage.png` → `apps/gallery/test/goldens/card_grid_dark.png`
+- `card_grid_rtl_testImage.png` → `apps/gallery/test/goldens/card_grid_rtl.png`
+
+Eyeball-verify shape/layout (test font = boxes), then:
+
+```bash
+git add apps/gallery/test/goldens/card_grid_light.png apps/gallery/test/goldens/card_grid_dark.png apps/gallery/test/goldens/card_grid_rtl.png
+git commit -F # "test(card): 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 spec/grounding)
+
+- **Spec coverage:** every shadcn v4 Card part is implemented — Card (T1), CardTitle/CardDescription (T2), CardHeader + CardAction + action-grid + `[.border-b]` (T3), CardContent (T4), CardFooter + `[.border-t]` (T5). The `gap-6`/`py-6`/`px-6` split, `rounded-xl`, `border`, `bg-card`/`text-card-foreground`, `shadow-sm`, `leading-none font-semibold`, `text-sm text-muted-foreground` all mapped in the grounding table and used verbatim in the steps.
+- **AGENTS.md rules:** semantic tokens only (no literal colors — Card needs none, not even the transparent literal); directional (`px`/`ps`/`pe`/`borderB`/`borderT`/`CrossAxisAlignment.start`); `package:flutter/widgets.dart` only (no Material — passes the gallery arch-guard); `.tw` single-box styling, layout via `FwColumn`/`FwRow`; one component file; golden coverage (variant-equivalent configs × brightness × RTL); rendered in `apps/gallery`.
+- **Type consistency:** `Card.children` (List), `CardHeader.title/description/action/bordered`, `CardFooter.children/bordered`, `CardTitle(child)`/`CardDescription(child)`/`CardContent(child)`/`CardAction(child)` positional — used identically across implementation, tests, gallery, and goldens.
+- **Placeholder scan:** none — every step has complete code or an exact command + expected output.
+- **Deviation honesty:** the one deviation (explicit header slots vs `has-[sibling]` grid) is a faithful-result/idiomatic-means choice with the mechanism stated, not a capability reduction (raises the bar — AGENTS.md §12). Non-interactive Card and block-level width are documented limitations with the compose-to-extend path, not silent gaps.
diff --git a/docs/superpowers/specs/2026-06-10-flutterbits-charter.md b/docs/superpowers/specs/2026-06-10-flutterbits-charter.md
index d8278a2..dd26fb5 100644
--- a/docs/superpowers/specs/2026-06-10-flutterbits-charter.md
+++ b/docs/superpowers/specs/2026-06-10-flutterbits-charter.md
@@ -167,6 +167,8 @@ 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.
+
---
## 9. Open questions (deferred, by explicit decision)