From e51eb3861154e8d3ac8e47c12fe27fd3e2fc9036 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 09:50:37 +0200 Subject: [PATCH 1/6] docs(plan): Card implementation plan TDD, subagent-driven plan for the Card primitive (shadcn v4) + gallery integration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-15-flutterbits-card.md | 1009 +++++++++++++++++ 1 file changed, 1009 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-flutterbits-card.md 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. From acd94ff5cee6c227bdaa541e63ace8167c2932c7 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 09:50:38 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(card):=20Card=20primitive=20(shadcn=20?= =?UTF-8?q?v4)=20=E2=80=94=20themed,=20compound,=20non-interactive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card + CardHeader/Title/Description/Action/Content/Footer; Material-free, styled through flutterwindcss semantic tokens (card/cardForeground/border/mutedForeground). Composed subcomponents; CardHeader uses explicit title/description/action slots (Flutter has no has-[sibling] selector); CardFooter is full-width so Spacer/end-alignment work like shadcn. 11 behavior tests: token reskin, mutedForeground override, action layout, divider presence+absence, RTL. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/gallery/lib/components/ui/card.dart | 198 +++++++++++++++ apps/gallery/test/card_behavior_test.dart | 283 ++++++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 apps/gallery/lib/components/ui/card.dart create mode 100644 apps/gallery/test/card_behavior_test.dart 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/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, + ); + }); +} From 5f3d78482e4a7891f9807293988d84e18563ff54 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 09:50:38 +0200 Subject: [PATCH 3/6] =?UTF-8?q?test(card):=20golden=20grid=20(light/dark/R?= =?UTF-8?q?TL)=20=E2=80=94=20provisional,=20CI=20Linux=20authoritative?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/gallery/test/card_golden_test.dart | 95 ++++++++++++++++++ apps/gallery/test/goldens/card_grid_dark.png | Bin 0 -> 4333 bytes apps/gallery/test/goldens/card_grid_light.png | Bin 0 -> 4405 bytes apps/gallery/test/goldens/card_grid_rtl.png | Bin 0 -> 4397 bytes 4 files changed, 95 insertions(+) create mode 100644 apps/gallery/test/card_golden_test.dart create mode 100644 apps/gallery/test/goldens/card_grid_dark.png create mode 100644 apps/gallery/test/goldens/card_grid_light.png create mode 100644 apps/gallery/test/goldens/card_grid_rtl.png 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/goldens/card_grid_dark.png b/apps/gallery/test/goldens/card_grid_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..6ff280bc1f3de2f7a07eb19e8e40311a62610f00 GIT binary patch literal 4333 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 R=iYgalXgz1%Hx>t{sqj2zw7`2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b0db9aa2ccd6a8af87bf30386ccf32078b1ad94b GIT binary patch literal 4397 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 Date: Mon, 15 Jun 2026 09:50:38 +0200 Subject: [PATCH 4/6] feat(gallery): showcase Card examples; smoke-test the card Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/gallery/lib/main.dart | 35 +++++++++++++++++++++++ apps/gallery/test/gallery_smoke_test.dart | 1 + 2 files changed, 36 insertions(+) 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/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); }); } From a935be0f165530f09b91395dd9f31c4c8f99bfa6 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 09:50:39 +0200 Subject: [PATCH 5/6] docs: primitives-before-structure reorder + Card canonical template (no-drift) Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 ++-- docs/superpowers/specs/2026-06-10-flutterbits-charter.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) 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/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) From b756489f19ff8ea05c591e67ae510220a353cc6d Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 09:56:09 +0200 Subject: [PATCH 6/6] test(card): re-baseline goldens on CI Linux (authoritative platform) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/gallery/test/goldens/card_grid_dark.png | Bin 4333 -> 4286 bytes apps/gallery/test/goldens/card_grid_light.png | Bin 4405 -> 4361 bytes apps/gallery/test/goldens/card_grid_rtl.png | Bin 4397 -> 4365 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/gallery/test/goldens/card_grid_dark.png b/apps/gallery/test/goldens/card_grid_dark.png index 6ff280bc1f3de2f7a07eb19e8e40311a62610f00..2a0fd205463328207da7c72c4f9ad38435f27350 100644 GIT binary patch literal 4286 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 ppzJI=;9Sn#qh_E!fdnceT0jFGkPE-BmNW-@ z=FwS!UB9!cnSPtZ(u}RBjh-6JUS5!NFS_dlw#r4EKv<^Wl&7AxYe!{qgtkk^3;ms2 zY{1F~77|29B>ND4=T;3oM$#yRrHp?osBUR!YFe2fEGKz+QDw?ICzE9y~mArDdlM6RQ{r=jP_-PIiZHwoy-zjvI}&q|mvC`Vw-C z?9pp&d=)(P%@KO4nv<93o^Jq52ZzG|QzeeslC&5`D=Rw#anjD8pWoO~cAzH#V(efb z(42LBWB2-X@)?dCT3EbK@EORS*bnie4xOU&NJdK^s@(_;3kySqgb04ngeK3W5eS0L zTZVDKqH!;}zaGiR$cSMy?a~i4j-Nzg?`mJFfee*%PY{br^>}#z&c@Z%MUC;?SJ8V{SENH(9GtmJ0NhxUrg5Nk_^5Cdth9!~E(aQHxyY;~ggyxMzC`Sa1m^pbXQ9)1Lp zMc)3)WroUAEN(|7d2o;;R-v@s#Stvjie0yjwIb)#&KM1nVc_t(@TzW<+2_mNX;Z*Ok} zq)oq~w)U`OUu*e=jell?!!lmbu+i+(#Vd|85BML4S6bF9nR_$c4wbK!r`t?JRovFb6JV&0sqyl}_9%MskDnHkQq%AF> z>Q~7z9-D&BHt{54BrV~4e?LEL2}dOFa@>>ZIU~oU)z;Q7PWnYN;w#HVR2)#c&KXqeSKeASzD_FLsY-|PRE0(!Ss015OH#HlI((D5(qy2{tjQhTA0N5 zvRF_OnM{T*sjJ-xPDxQZi$o%#kc?lm)Ts<`LHILqjD*RjdGL?n@Dj;5Lv*gawWXTD z;PFRCBLkYQ%-kXjXA>-pS1=;M^Mqri*ek&e4#{CwFbb z*XDxc`Z}JD3!_+JKXFxt3{y)bFO_0t-r4?g)tau zv@cU|_ko4~hwA9-#Sv(Yw6@Ov{{HmB!oqx@SvM<2qwqHgeT3zKa60Z;=-7Z#wO5M2 z1Y>siqy|TshHTS`N64i3f%tSoKv?{y(MR-0+0qrc$XfqcOtD2~zL~&>eMnO()!I~H zD7*QfL05<3#(o`YWAJ*WQAnc8lG2=PQz;aRb2fqOJp2J3gK4^aGC8O|De>fMu~^)P zr0Kh{uK+yF+M%zHUyPVA_N&ax%ggohV)*GjzMrNE>38?=pcqw@T#Al{1F=w;*7j4_ z`8)hiFtX`E*TBV$ig-IBr?B`_I2^7Y7Z<1L^08m)p5fu)>C2Zc9XjTj93C9}oS4p3 zIIGQGvEyeE_>QZy)#4{lp2WVe-Dq%)o*>mm@mqM83#RB@*+%xNpQI|o);C88J&$w( zNd4-QCnq3a4MiS~8OU!=sy^p6_)rs?-m|<64)MA(Ne5)d*7*&JB4EWx9IoY|XeGyu ztv7FyN^}>dQ@x{#c_>yk_BQCzN5H#>A3PW{od$7Q{tSVLndrRLh-30UjeXHQ+tJa{ zAk?%u3xsFiEkn3O?^vae0>g;T3mw_+Y?9_p@zOhX&|s?uEk8bh{3a-3JT?cN9Th7E z7}zEEd|h4LKw?2btQd0qLS$rQU0E3|FZ8@Ibw6`?;{u?jo&xml(qRkU^hGQ)iS>=n z!QcxUOmeFZn3)!jNi^P107rS?u(&we@&LNx`>uF8kB}4X^KpMe032p2Se~CaXfN8a z!a$X(HKWD8_w3>$|DK*&>Er90nQQKW6G+3u!a6@9Dh(x4 zdUr)&yqyt{?5)@1`Sr;^BgP}ux=V6kPWI<0X*=-t`T$kiAXKwCO?2iA6OVb(w1Cy4(zlJ8pXH8r&(_P=pitgepEmMvQ*NSdw3ftUfFQ1iQ{ z|HCLMWLWd>ckk|0?CE^-Lci)DoJ;p`R|NQW4au(- zoTr3B2n%RA*lVCi6)i1mWVu}4zsPb{N{#e@NF@6B`EfBSPs~QJ@Nt7~{`bmHg zVL`gAjjO@Q-)`4QzjNnK3$vkN+52Dhw>MCKmY3}>D+7w*n){%l!9#9#-ZtU5u8fmwHJ3|1Pwgy^YqPUi`wnN8I=b5r5Mx}Ytjdrr_RdgH_~cSQN; HLD&8X)b5<- delta 3332 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 Bw?zN| diff --git a/apps/gallery/test/goldens/card_grid_rtl.png b/apps/gallery/test/goldens/card_grid_rtl.png index b0db9aa2ccd6a8af87bf30386ccf32078b1ad94b..9dd70748d4d9d43907ec67f4f8bc63068b6806de 100644 GIT binary patch literal 4365 zcmb`K4LH+#8^<@4lF~|NDleTmPb4*kD2*d?oL<4LbeRMo!UQ!(gW#Oa_ z2Qe=(%#5NH;jox&tcX2~&Fh8@&)@S_U3H$E=X!qE^}l}C|9}1dzu*14@B6wx-#h*X z8>@{9I}|`5(8lB6BTs`s;BSFvV%=Kc3(L8Z3ruo>$Bv&_2aL#d7l^<+IPkR9F;H=v z>Iew5nQ|OyekSzZcpvJbDsR4i(#!MlPqjb6NGj(qT$9s~sZ$BpZrU@qWlQ8G?fX@~ ze-(aF`!Ql|z(Q)LOGCP-`#j&`e%zL=+I#jF?@_7d?KrKia`ln5&T_o{aq3ia`Ls#n z{$i3rd`4gDOr+_X*k7Y)Wc)k0Fv&pp)#P?gO)!5bN4ssCuE*kVErmsE!d4A5{W#?ti)XJn%IUnjKsu5y7ZyHhD&K`YAVy-o@S@qbo*(XQo9Wb zRj5`%_Vx2K;`8|=V6BZ)8rp;)ox72zF|Faj4?03NvJUje;r0&?4{y5RTM4o#DJ|__ z_w-y$$eHVY^5h8{MG$ahdPaik)FE_&=(QI+B_(C(xtD^ToRa!c6%I9o%r=37`4f{TDA@T=Lalqw*txj^($6a8M?%Xk+T9OE(;jo;l zD2d3G&K0Vf;C=Du&Yj~-%c>M)W@ZkH#@XV;oU^(Sw0@)Jvatk>TjqsbIte){ifdz8 zEj7jMmF#g*mDUQKLl^rl$L2EKn~td(Qkk$b z_S>*d>Tu10?O?fsVXtbvnu&KxO2M~|JEcJ&cDNAP&Mn{W+6R{V%;Zu(_BDw_@}C}V zZATQd$G?jVilz9Y4u?%$c-fXb>=42bCaOU-jR`VUah%#P{kD6D^#$Il+9yw*^k0}6 zxqUQ!P~5arG#RL%`0y_=-#W{z`^-du?8qu(n;cnM64RBiriwQ~PwY4yx8v+E|FMb% z65mSELOtv3?BuCa(3M6zSojmv?SsZi(XL}tb5ZI|qobF~OG@-!X@Pruj$|eS|H41H z(Nsi?#ndp#FL`EuziU-Vq5p#{9!_nB>_^$Oz%OHyh2>>3pYy`|jRLfugGxeEp`JZD{tH_6F?G{Z%$m<#^Znh2Y;GZ*Wy-!( z%Qv>|x|If!@m{~G$)U8SrdL;2Hz`6Lq}eqR^aymdR&Mk6CqY*q|9__4&k*3AL{Cr8 zXNWbhb3Q&kzb)7OISVuTFk^xS^T+JI)6(cU;-CLH<2I9USRlb>$^S{;04L*pDAb@5 z`grqT|o)t5lj{v9q?Sj%E(mptXtz);nm;L^2H)%e*sY8!&o; zc~H_RHx>jU21A7wrvp^P81KL1nJWlDR^q(HcP(B0_{3>g+u27Zx_37b5JZ znF!*+itIjWh!75kFQbt&i9}*kQ=I1iHc)I%?Nd1-ZbcUSgD2x%I%Q_Xwn@x;tfofrrtZa=zC+G>|)vnYLj zTN6v|6XllH*L9EMDW=kirwuoD8WDm7oNBJA`=TZC3<@E7+mr}h#*euCmR8&O!UcP*}RwAJC+Oy4;C(QTrg6hKTLw}>My%HQe z8wZ(8VYm#SP$VxF=tb; z^KAfAEu=BAz_PN8>}&Eh811#sH$f%^^sfgJX7Y#KwHm2w0=DJE`JMUyj7-1I z#D1~Tf2`7U92^}<*REajZa*vD{8$1X zOE-b`pg#z_tL&*Zfv$wanvOpIMG3YU-=wxqRqi7RMo6#~rj?;X-Hg|Lu#$u-us*!nLdcVQBdz%*F7)^h{v zt=7z$mR#>{02@_2S-pe7ZdOPRoL{r_rUMN+4YV@0tl3KdDwY5NvV8a-qJzSwvG}va z>43oW6Nl55s43vvUqPWtZdCj@d3t)JcTUZF^ z&GWLCPa}QPArK0#Z`6%WSiAw$$9#Du^|{-6SKa`frFh{$kJ5xyYQhq(ULsToC#fpnW^*;=&FW(!_*`=DlR;ZWw+bAM_nkwI*_Y$_SNt zWOiE9D*5MeJex$9kvLP>eG3y$mE?_ZoVkwbKpsI7ItyR~G20Udg}Y1H`F455`*d|{ zdjZJ;u)wo$bR-Tuc6pIt1_+*|Qb3MO3aG*?v*;zMMphC!@}F3-7~G*!)=lL2nwGo15DKA@VEQvXMp% zotvpb6`nPL4exiW2@+j{x`zhiaDu`j2mV=G<#uJux3I5Ev<$4v^;HxP;qiFW9uB9r pvbHvIzJld02}JrLk>ZqRi)QSFEUcvs2)3d)j$7Iwi;rP`{5!6su^|8e literal 4397 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