Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
198 changes: 198 additions & 0 deletions apps/gallery/lib/components/ui/card.dart
Original file line number Diff line number Diff line change
@@ -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 `<Card><CardHeader/>…</Card>`. 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 `<div>`), 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
/// `<div>` 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<Widget> 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: <Widget>[title, if (description != null) description!],
);

final Widget content =
action == null
? stack
: FwRow(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[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<Widget> 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;
}
}
35 changes: 35 additions & 0 deletions apps/gallery/lib/main.dart
Original file line number Diff line number Diff line change
@@ -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());

Expand Down Expand Up @@ -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.')),
],
),
],
),
),
],
),
),
Expand Down
Loading
Loading