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)