diff --git a/apps/gallery/lib/components/ui/input.dart b/apps/gallery/lib/components/ui/input.dart new file mode 100644 index 0000000..11c061d --- /dev/null +++ b/apps/gallery/lib/components/ui/input.dart @@ -0,0 +1,247 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; + +/// A Material-free, themeable single-line text field — shadcn parity. Copy-paste +/// source you own. +/// +/// **Intention-revealing:** `Input(controller: c, placeholder: 'Email')` reads as +/// the artifact. It is a styled [EditableText] (the `widgets.dart` text-editing +/// primitive — `TextField` is Material and off-limits) wrapped in a single `.tw` +/// box. Fill/border/ring/text are all `flutterwindcss` semantic tokens (`input`, +/// `ring`, `destructive`, `foreground`, `mutedForeground`, `primary`), so a pasted +/// theme reskins it. Layout is faithful to shadcn v4 `h-9 w-full rounded-md border +/// border-input bg-transparent px-3 text-base`. +/// +/// **States** (faithful to shadcn): default `border-input`; focused +/// `border-ring` + a 3px `ring/50` focus ring; [invalid] `border-destructive` +/// (+ `destructive/20` ring when focused); disabled ([enabled] = false) +/// `opacity-50` + non-interactive. For a text field, focus == focus-visible (you +/// focus a field to type), so the ring shows on any focus. +/// +/// **Managed controller/focusNode.** Pass your own [controller]/[focusNode], or +/// omit them and the widget creates (and disposes) its own — the standard Flutter +/// pattern. +/// +/// **Block-level** (`w-full`): the field fills — and requires — a bounded-width +/// parent (place it in a constrained column/`SizedBox`/`Expanded`). Single-line +/// (`maxLines: 1`); a multi-line `Textarea` is a separate primitive. +/// +/// **Accessibility:** [EditableText] reports text-field semantics + the current +/// value; pass [semanticLabel] to name the field (e.g. "Email") — unlike Button's +/// `semanticLabel`, this does NOT replace the value (a screen reader announces +/// both the name and the typed text, which is correct for an input). +/// +/// **Faithful deviations (documented, not bugs):** `shadow-xs`, `md:text-sm`, and +/// `dark:bg-input/30` are dropped (an input's depth is its border+ring; 16px is +/// the mobile-first default; the dark fill needs a brightness-only token knob we +/// don't have — same precedent as Badge's dropped `dark:bg-destructive/60`). +/// Selected text keeps its `foreground` color (EditableText can't recolor text on +/// selection). **Not yet built (feasible, AGENTS.md §11b):** a custom selection +/// toolbar/handles (`TextSelectionControls`) — v1 ships keyboard + basic selection. +class Input extends StatefulWidget { + const Input({ + super.key, + this.controller, + this.focusNode, + this.placeholder, + this.enabled = true, + this.invalid = false, + this.obscureText = false, + this.keyboardType, + this.textInputAction, + this.onChanged, + this.onSubmitted, + this.autofocus = false, + this.semanticLabel, + }) : assert( + !autofocus || enabled, + 'Input: autofocus requires enabled: true — a disabled field must not steal focus.', + ); + + /// External text controller. When null, the field manages its own. + final TextEditingController? controller; + + /// External focus control. When null, the field manages its own. + final FocusNode? focusNode; + + /// Hint shown in `mutedForeground` while the field is empty (shadcn + /// `placeholder:`). + final String? placeholder; + + /// Whether the field is interactive. `false` ⇒ `opacity-50`, read-only, and + /// non-focusable (shadcn `disabled:`). + final bool enabled; + + /// Whether to render the error treatment (shadcn `aria-invalid:` — a + /// `destructive` border, and a `destructive/20` ring when focused). The caller + /// owns validation; this is the visual flag. + final bool invalid; + + /// Obscures input (passwords). Maps to [EditableText.obscureText]. + final bool obscureText; + + /// The soft-keyboard type (e.g. `TextInputType.emailAddress`). + final TextInputType? keyboardType; + + /// The IME action button (e.g. `TextInputAction.next`). + final TextInputAction? textInputAction; + + /// Called on every edit with the new text. + final ValueChanged? onChanged; + + /// Called when the user submits (IME action / Enter). + final ValueChanged? onSubmitted; + + /// Whether to focus the field on first build. + final bool autofocus; + + /// Screen-reader name for the field (e.g. "Email"). Added alongside — not in + /// place of — the field's value. + final String? semanticLabel; + + @override + State createState() => _InputState(); +} + +class _InputState extends State { + TextEditingController? _internalController; + FocusNode? _internalFocusNode; + TextEditingController get _controller => + widget.controller ?? (_internalController ??= TextEditingController()); + FocusNode get _focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode()); + + @override + void initState() { + super.initState(); + _controller.addListener(_onChangedInternal); + _focusNode.addListener(_onFocusChanged); + } + + @override + void didUpdateWidget(Input old) { + super.didUpdateWidget(old); + if (widget.controller != old.controller) { + (old.controller ?? _internalController)?.removeListener(_onChangedInternal); + if (old.controller != null && widget.controller == null) { + _internalController = TextEditingController(text: old.controller!.text); + } else if (widget.controller != null) { + _internalController?.dispose(); + _internalController = null; + } + _controller.addListener(_onChangedInternal); + } + if (widget.focusNode != old.focusNode) { + (old.focusNode ?? _internalFocusNode)?.removeListener(_onFocusChanged); + if (old.focusNode != null && widget.focusNode == null) { + _internalFocusNode = FocusNode(); + } else if (widget.focusNode != null) { + _internalFocusNode?.dispose(); + _internalFocusNode = null; + } + _focusNode.addListener(_onFocusChanged); + } + } + + @override + void dispose() { + (widget.controller ?? _internalController)?.removeListener(_onChangedInternal); + (widget.focusNode ?? _internalFocusNode)?.removeListener(_onFocusChanged); + _internalController?.dispose(); + _internalFocusNode?.dispose(); + super.dispose(); + } + + void _onChangedInternal() { + // Only the placeholder depends on the text, so skip the rebuild when there is + // none (avoids a full rebuild on every keystroke in a placeholder-less field). + if (mounted && widget.placeholder != null) setState(() {}); + } + + void _onFocusChanged() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final c = context.fw.colors; + final focused = _focusNode.hasFocus && widget.enabled; + + final Color borderColor = widget.invalid ? c.destructive : (focused ? c.ring : c.input); + final Color? ringColor = + focused + ? (widget.invalid ? c.destructive : c.ring).withValues( + alpha: widget.invalid ? 0.2 : 0.5, + ) + : null; + + final textStyle = TextStyle(fontSize: FwFontSize.base.px, color: c.foreground); + + final editable = EditableText( + controller: _controller, + focusNode: _focusNode, + readOnly: !widget.enabled, + obscureText: widget.obscureText, + keyboardType: + widget.keyboardType ?? + (widget.obscureText ? TextInputType.visiblePassword : TextInputType.text), + textInputAction: widget.textInputAction, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + autofocus: widget.autofocus, + maxLines: 1, + style: textStyle, + cursorColor: c.foreground, + backgroundCursorColor: c.mutedForeground, + // shadcn `selection:bg-primary`, softened to 30% alpha so the `foreground` + // text stays readable through the highlight. + selectionColor: c.primary.withValues(alpha: 0.3), + cursorWidth: 2, + ); + + final showPlaceholder = widget.placeholder != null && _controller.text.isEmpty; + final content = Stack( + alignment: AlignmentDirectional.centerStart, + children: [ + if (showPlaceholder) + Text( + widget.placeholder!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: FwFontSize.base.px, color: c.mutedForeground), + ), + editable, + ], + ); + + FwStyled box = + content.tw + .bg(const Color(0x00000000)) + .border(1, color: borderColor) + .roundedMd + .px(3) + .py(2) + .minH(9) + .wFull; + if (ringColor != null) box = box.ring(3, color: ringColor); + if (!widget.enabled) box = box.opacity(0.5); + + Widget result = box; + if (!widget.enabled) { + // `IgnorePointer` blocks taps; `ExcludeFocus` removes the field from keyboard + // traversal (and denies programmatic focus) so a disabled input is truly + // non-focusable — without mutating a caller-owned [focusNode]. Faithful to + // shadcn `disabled:pointer-events-none` + the disabled-tabindex behavior. + result = ExcludeFocus(child: IgnorePointer(child: result)); + } + if (widget.semanticLabel != null) { + // Merge the name ONTO the field's own node (which already carries the + // text-field role + the current value) so a screen reader announces one + // "name, edit text, value" — not a separate empty-valued label node beside + // an unnamed field. (A bare `Semantics(textField: true, label:)` wrapper + // would create that competing second node; `MergeSemantics` collapses them.) + result = MergeSemantics(child: Semantics(label: widget.semanticLabel, child: result)); + } + return result; + } +} diff --git a/apps/gallery/lib/main.dart b/apps/gallery/lib/main.dart index 28f14f2..26618a5 100644 --- a/apps/gallery/lib/main.dart +++ b/apps/gallery/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutterwindcss/flutterwindcss.dart'; import 'components/ui/button.dart'; import 'components/ui/card.dart'; import 'components/ui/badge.dart'; +import 'components/ui/input.dart'; void main() => runApp(const GalleryApp()); @@ -128,6 +129,22 @@ class GalleryApp extends StatelessWidget { Badge(leading: _Dot(), child: Text('Verified')), ], ), + const SizedBox(height: 24), + Text('Inputs').tw.textSize(20).weight(FwFontWeight.bold), + const SizedBox(height: 12), + const SizedBox( + width: 280, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Input(placeholder: 'Email'), + SizedBox(height: 12), + Input(invalid: true, placeholder: 'Invalid'), + SizedBox(height: 12), + Input(enabled: false, placeholder: 'Disabled'), + ], + ), + ), ], ), ), diff --git a/apps/gallery/test/gallery_smoke_test.dart b/apps/gallery/test/gallery_smoke_test.dart index 899cc4b..334174b 100644 --- a/apps/gallery/test/gallery_smoke_test.dart +++ b/apps/gallery/test/gallery_smoke_test.dart @@ -12,5 +12,6 @@ void main() { expect(find.text('primary'), findsWidgets); expect(find.text('Create project'), findsWidgets); expect(find.text('Badges'), findsWidgets); + expect(find.text('Inputs'), findsWidgets); }); } diff --git a/apps/gallery/test/goldens/input_grid_dark.png b/apps/gallery/test/goldens/input_grid_dark.png new file mode 100644 index 0000000..a5387a0 Binary files /dev/null and b/apps/gallery/test/goldens/input_grid_dark.png differ diff --git a/apps/gallery/test/goldens/input_grid_light.png b/apps/gallery/test/goldens/input_grid_light.png new file mode 100644 index 0000000..7215f4e Binary files /dev/null and b/apps/gallery/test/goldens/input_grid_light.png differ diff --git a/apps/gallery/test/goldens/input_grid_rtl.png b/apps/gallery/test/goldens/input_grid_rtl.png new file mode 100644 index 0000000..060be5a Binary files /dev/null and b/apps/gallery/test/goldens/input_grid_rtl.png differ diff --git a/apps/gallery/test/input_behavior_test.dart b/apps/gallery/test/input_behavior_test.dart new file mode 100644 index 0000000..e63b72d --- /dev/null +++ b/apps/gallery/test/input_behavior_test.dart @@ -0,0 +1,206 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; +import 'package:flutterbits_gallery/components/ui/input.dart'; + +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: 280, child: child), + ), + ), + ), + ), +); + +Border? _borderOf(WidgetTester t) { + final dec = t + .widgetList(find.byType(DecoratedBox)) + .map((d) => d.decoration) + .whereType() + .firstWhere((d) => d.border != null, orElse: () => const BoxDecoration()); + return dec.border as Border?; +} + +void main() { + // ---- Group 1: text entry ---- + + testWidgets('renders an EditableText and accepts typed text', (t) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, Input(controller: controller))); + expect(t.takeException(), isNull); + expect(find.byType(EditableText), findsOneWidget); + await t.enterText(find.byType(EditableText), 'hello'); + await t.pump(); + expect(controller.text, 'hello'); + }); + + // ---- Group 2: placeholder + border tokens + focus ring ---- + + testWidgets('placeholder shows when empty and disappears once text is entered', (t) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Input(controller: controller, placeholder: 'Email'), + ), + ); + expect(find.text('Email'), findsOneWidget); + await t.enterText(find.byType(EditableText), 'a@b.com'); + await t.pump(); + expect(find.text('Email'), findsNothing); + }); + + testWidgets('default border uses the input token; invalid uses destructive', (t) async { + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input())); + expect(_borderOf(t)?.top.color, FwTokens.light.colors.input); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input(invalid: true))); + expect(_borderOf(t)?.top.color, FwTokens.light.colors.destructive); + }); + + testWidgets('focusing shows a ring and recolors the border to ring', (t) async { + final focus = FocusNode(); + addTearDown(focus.dispose); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, Input(focusNode: focus))); + expect(_borderOf(t)?.top.color, FwTokens.light.colors.input); + focus.requestFocus(); + // Two pumps: the first lets FocusManager apply the focus change (via + // scheduleMicrotask → applyFocusChangesIfNeeded → notifyListeners → + // setState marks dirty); the second rebuilds the widget with hasFocus=true. + await t.pump(); + await t.pump(); + expect(_borderOf(t)?.top.color, FwTokens.light.colors.ring); + final hasRing = t + .widgetList(find.byType(DecoratedBox)) + .map((d) => d.decoration) + .whereType() + .any((d) => (d.boxShadow?.isNotEmpty ?? false)); + expect(hasRing, isTrue, reason: 'focus must add a ring (boxShadow)'); + }); + + // ---- Group 3: disabled / obscure / callbacks / semantics ---- + + testWidgets('disabled is read-only, non-interactive, and dimmed', (t) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, Input(controller: controller, enabled: false)), + ); + // The IgnorePointer must specifically wrap the field (not just exist somewhere). + expect( + find.ancestor(of: find.byType(EditableText), matching: find.byType(IgnorePointer)), + findsOneWidget, + ); + expect(t.widget(find.byType(EditableText)).readOnly, isTrue); + final hasDim = t.widgetList(find.byType(Opacity)).any((o) => o.opacity == 0.5); + expect(hasDim, isTrue); + }); + + testWidgets('disabled field is non-focusable (excluded from focus traversal)', (t) async { + final focus = FocusNode(); + addTearDown(focus.dispose); + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, Input(focusNode: focus, enabled: false)), + ); + focus.requestFocus(); + await t.pump(); + await t.pump(); + expect(focus.hasFocus, isFalse, reason: 'a disabled input must not take keyboard focus'); + }); + + testWidgets('obscureText is forwarded', (t) async { + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input(obscureText: true))); + expect(t.widget(find.byType(EditableText)).obscureText, isTrue); + }); + + testWidgets('onChanged / onSubmitted fire', (t) async { + String? changed; + String? submitted; + final focus = FocusNode(); + addTearDown(focus.dispose); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Input(focusNode: focus, onChanged: (v) => changed = v, onSubmitted: (v) => submitted = v), + ), + ); + await t.enterText(find.byType(EditableText), 'hi'); + expect(changed, 'hi'); + await t.testTextInput.receiveAction(TextInputAction.done); + await t.pump(); + expect(submitted, 'hi'); + }); + + testWidgets('semanticLabel names the field without hiding its value', (t) async { + final handle = t.ensureSemantics(); + final controller = TextEditingController(text: 'typed'); + addTearDown(controller.dispose); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Input(controller: controller, semanticLabel: 'Email'), + ), + ); + expect(find.bySemanticsLabel('Email'), findsOneWidget); + // The value must remain reachable in semantics alongside the name (NOT replaced + // — deliberately unlike Button/Badge, where semanticLabel excludes the child). + expect(t.getSemantics(find.byType(EditableText)).value, 'typed'); + handle.dispose(); + }); + + // ---- Group 4: managed controller/focusNode lifecycle ---- + + testWidgets('manages its own controller when none is given (accepts text, disposes clean)', ( + t, + ) async { + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input(placeholder: 'P'))); + await t.enterText(find.byType(EditableText), 'self-managed'); + await t.pump(); + expect(find.text('P'), findsNothing); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const SizedBox())); + expect(t.takeException(), isNull); + }); + + testWidgets('swapping an external controller in/out does not crash', (t) async { + final external = TextEditingController(text: 'X'); + addTearDown(external.dispose); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input())); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, Input(controller: external))); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input())); + expect(t.takeException(), isNull); + // Prove the external controller was NOT disposed by Input: reading `.text` + // after dispose throws ("used after being disposed"), so a clean read here is + // the assertion that Input only ever disposes its own internal controller. + expect(external.text, 'X'); + }); + + // ---- Group 5: RTL + theme-reskin coverage ---- + + testWidgets('renders under RTL with no overflow/exception', (t) async { + await t.binding.setSurfaceSize(const Size(360, 200)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.rtl, const Input(placeholder: 'بريد إلكتروني')), + ); + expect(t.takeException(), isNull); + expect(find.text('بريد إلكتروني'), findsOneWidget); + }); + + testWidgets('reskins with the active theme (dark input token border)', (t) async { + await t.pumpWidget(_frame(FwTokens.dark, TextDirection.ltr, const Input())); + expect(_borderOf(t)?.top.color, FwTokens.dark.colors.input); + }); +} diff --git a/apps/gallery/test/input_golden_test.dart b/apps/gallery/test/input_golden_test.dart new file mode 100644 index 0000000..d594e87 --- /dev/null +++ b/apps/gallery/test/input_golden_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; +import 'package:flutterbits_gallery/components/ui/input.dart'; + +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() { + final focusNode = FocusNode()..requestFocus(); + return RepaintBoundary( + key: const ValueKey('input_grid'), + child: SizedBox( + width: 280, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Input(placeholder: 'Placeholder'), + const SizedBox(height: 12), + Input(controller: TextEditingController(text: 'Typed value')), + const SizedBox(height: 12), + Input(focusNode: focusNode, controller: TextEditingController(text: 'Focused')), + const SizedBox(height: 12), + const Input(invalid: true, placeholder: 'Invalid'), + const SizedBox(height: 12), + const Input(enabled: false, placeholder: 'Disabled'), + ], + ), + ), + ); +} + +void main() { + setUp(() => EditableText.debugDeterministicCursor = true); + tearDown(() => EditableText.debugDeterministicCursor = false); + + const surfaceSize = Size(320, 420); + + testWidgets('input 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 t.pump(); + await expectLater( + find.byKey(const ValueKey('input_grid')), + matchesGoldenFile('goldens/input_grid_light.png'), + ); + }); + + testWidgets('input 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 t.pump(); + await expectLater( + find.byKey(const ValueKey('input_grid')), + matchesGoldenFile('goldens/input_grid_dark.png'), + ); + }); + + testWidgets('input grid — light RTL', (t) async { + await t.binding.setSurfaceSize(surfaceSize); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.rtl, _grid())); + await t.pump(); + await expectLater( + find.byKey(const ValueKey('input_grid')), + matchesGoldenFile('goldens/input_grid_rtl.png'), + ); + }); +} diff --git a/docs/superpowers/plans/2026-06-15-flutterbits-input.md b/docs/superpowers/plans/2026-06-15-flutterbits-input.md new file mode 100644 index 0000000..da14ba9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-flutterbits-input.md @@ -0,0 +1,737 @@ +# Input 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 `Input` — the fourth flutterbits primitive and the keystone of the form cluster — as a Material-free, themeable, shadcn-v4-faithful single-line text field in `apps/gallery`, with behavior + golden tests and CI gating. + +**Architecture:** shadcn's `Input` is a styled ``; the Flutter-faithful equivalent is a styled **`EditableText`** (the `package:flutter/widgets.dart` text-editing primitive — TextField/CupertinoTextField are off-limits) wrapped in one `.tw` box. The widget is a `StatefulWidget` that **manages an optional controller/focusNode** (creates+disposes its own when the caller passes none), listens to the focusNode to drive a focus-visible **ring**, and listens to the controller to toggle a **placeholder** overlay. Fill/border/ring/text all come from `flutterwindcss` semantic tokens (`input`, `ring`, `destructive`, `foreground`, `mutedForeground`, `primary`), so a pasted theme reskins it. States: default / focused / invalid / disabled, faithful to shadcn's `border-input` → `focus-visible:border-ring ring-ring/50 ring-[3px]` → `aria-invalid:border-destructive ring-destructive/20` → `disabled:opacity-50 pointer-events-none`. + +**Tech Stack:** Dart / Flutter (`package:flutter/widgets.dart` + `package:flutter/services.dart` for `TextInputType`/`TextInputAction` — both allowed, NOT material), `flutterwindcss` engine (`.tw`, `context.fw`), `flutter_test` goldens (CI Linux authoritative). + +--- + +## Grounding (read before starting) + +**Authoritative shadcn v4 Input class** (from `ui.shadcn.com/r/styles/new-york-v4/input.json`, verbatim, fetched 2026-06-15 — the `` className): + +``` +h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:… placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 +``` + +**Mapping (and what is intentionally dropped/deviated — recorded, not silent):** +- `h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base` → `.tw.bg(transparent).border(1,color:input).roundedMd.px(3).py(2).minH(9).wFull.textSize(FwFontSize.base.px)`. **`py-2` not `py-1`:** symmetric `py(2)` centers the text to the `h-9` (36px) box AND lets it grow with OS text scaling instead of a fixed `.h` clipping it — the **Button audit lesson** (Button md uses `minH(9)+py(2)`). The visual height matches shadcn's `h-9`. `text-base` = 16px (`FwFontSize.base.px`) — kept (NOT `md:text-sm`), because **mobile-first**: 16px is the iOS no-zoom threshold and the right default for a touch field (the `md:text-sm` desktop refinement is dropped, documented). +- `placeholder:text-muted-foreground` → a `Text(placeholder)` overlay in `mutedForeground`, shown only when the field is empty. +- `outline-none` → EditableText has no platform outline; the ring is ours. +- `focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50` → when focused: border becomes `ring`, plus `.ring(3, color: c.ring.withValues(alpha: 0.5))` (token-derived alpha, NOT a literal — allowed). For a text field, focus == focus-visible (you focus a field to type), so the ring shows on any focus (unlike Button's keyboard-only ring). +- `aria-invalid:border-destructive ring-destructive/20` → when `invalid`: border becomes `destructive`; the focus ring (when focused) uses `c.destructive.withValues(alpha: 0.2)`. +- `disabled:opacity-50 disabled:pointer-events-none` → `.opacity(0.5)` + `IgnorePointer` + `EditableText.readOnly: true`. +- `selection:bg-primary` → `EditableText.selectionColor: c.primary.withValues(alpha: 0.3)` (semi-transparent so the `foreground` text stays readable). **Deviation:** selected text keeps `foreground` color (EditableText can't recolor text-on-selection to `primary-foreground` without custom paint); documented. +- `shadow-xs` → **dropped** (an input's depth is carried by its border + focus ring; `shadow-xs` on a transparent field is barely perceptible and would fight the ring). Documented. +- `dark:bg-input/30` → **dropped** (flat `bg-transparent` both brightnesses — the token system has no dark-only 30%-fill knob without brightness detection; same precedent as Badge's dropped `dark:bg-destructive/60`). Documented. Light mode (the default) is `bg-transparent` and faithful. +- `file:*` → N/A (no file-input variant in v1). + +**Engine API facts (verified against `packages/flutterwindcss/lib/src/`):** +- `FwColors` has `input` (`colors.dart:103`), plus `ring`, `destructive`, `foreground`, `mutedForeground`, `primary`, `background`. +- `FwFontSize.base.px == 16` (`typography.dart:15`); `.sm.px == 14`. +- `.tw` setters: `.bg(Color)`, `.border(1,{color})`, `.roundedMd` (getter), `.px(n)`, `.py(n)`, `.minH(n)`, `.wFull` (getter ⇒ width double.infinity), `.textSize(double)`, `.opacity(double)`, `.ring(double width,{required Color color, double offset=0, Color? offsetColor})` (`fw_style_ops.dart:710`). +- `Color.withValues(alpha:)` is the sanctioned alpha API (AGENTS.md §3.8). A token-derived color (`c.ring.withValues(alpha: 0.5)`) is NOT a banned literal. + +**`EditableText` (the Material-free text primitive) — REQUIRED params** (verify on first compile; a missing one is a compile error): `controller`, `focusNode`, `style`, `cursorColor`, `backgroundCursorColor`. We also set `readOnly`, `obscureText`, `keyboardType`, `textInputAction`, `onChanged`, `onSubmitted`, `autofocus`, `selectionColor`, `maxLines: 1`, `cursorWidth: 2`. **`maxLines: 1`** → single-line (Textarea is a separate future primitive). No `selectionControls` is passed → no custom selection toolbar/handles in v1 (keyboard + basic selection work). **This is a documented "not yet built", NOT a "can't"** (feasible via a `TextSelectionControls`; AGENTS.md §11b) — record it in the class doc and gallery docs. + +**Canonical patterns to mirror:** `apps/gallery/lib/components/ui/button.dart` (StatefulWidget interaction component, focus ring via `.ring(...)`, `minH+py` centering, `semanticLabel`, file-header doc-comment), `badge.dart`/`card.dart` (token discipline, directional, one-file). Analyzer-clean `--fatal-infos --fatal-warnings`; `dart format` 100-col; one component per file; `///` on every public member. + +**Golden harness to mirror:** `apps/gallery/test/badge_golden_test.dart` (FwTheme→Directionality→MediaQuery→ColoredBox→Align→Padding→RepaintBoundary(key:), `setSurfaceSize`, `matchesGoldenFile('goldens/.png')`, three goldens light/dark/RTL). **CI (Linux) is authoritative** (AGENTS.md §9); local `--update-goldens` is provisional. (Badge precedent: provisional Windows goldens may or may not match CI — if the `gallery` job's golden step fails, re-baseline from the `gallery-golden-failures` artifact, see Task 11.) + +**Design decisions locked:** +1. `Input` is a **`StatefulWidget`** managing an optional `controller`/`focusNode` (creates+disposes internal ones when null; standard didUpdateWidget re-subscription). +2. Single-line (`maxLines: 1`). Multi-line is a separate `Textarea` primitive (not this plan). +3. **No `variant`/`size` enums** — shadcn's Input has neither. States are boolean props: `enabled`, `invalid`. +4. API: `Input({controller, focusNode, placeholder, enabled=true, invalid=false, obscureText=false, keyboardType, textInputAction, onChanged, onSubmitted, autofocus=false, semanticLabel})`. +5. **Block-level / full-width** (`wFull`) — like Card, it requires a bounded-width parent (documented). +6. Accessibility: `EditableText` provides text-field semantics + value; when `semanticLabel` is supplied, wrap in `Semantics(textField: true, label: semanticLabel)` **without** `excludeSemantics` (an input's *value* must still announce alongside its name — deliberately different from Button/Badge, where the label *replaces*). +7. Focus ring shows on any focus (focus == focus-visible for a text field). + +**No-drift scope:** Input is another primitive (no new canonical template). Task 10 records it in charter §8 and verifies the docs tab stays overview-only. **Deferred (recorded):** registry promotion (with the registry/CLI plan, as for button/card/badge); custom selection toolbar/handles (`TextSelectionControls`) and a `Textarea` multi-line sibling (both "not yet built", feasible — §11b). + +--- + +## File structure + +- Create: `apps/gallery/lib/components/ui/input.dart` — `Input` (one file). +- Create: `apps/gallery/test/input_behavior_test.dart` — text entry, placeholder toggle, token/border per state, focus ring, invalid, disabled, obscure, callbacks, semantics, RTL, reskin. +- Create: `apps/gallery/test/input_golden_test.dart` — golden grid (light/dark/RTL). +- Create (provisional): `apps/gallery/test/goldens/input_grid_{light,dark,rtl}.png`. +- Modify: `apps/gallery/lib/main.dart` — Inputs showcase section. +- Modify: `apps/gallery/test/gallery_smoke_test.dart` — assert an Input example renders. +- Modify: `docs/superpowers/specs/2026-06-10-flutterbits-charter.md` — §8 progress note. + +--- + +## The complete component (target for Tasks 1–7) + +Build this incrementally via the TDD tasks below; this is the finished `apps/gallery/lib/components/ui/input.dart`: + +```dart +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; + +/// A Material-free, themeable single-line text field — shadcn parity. Copy-paste +/// source you own. +/// +/// **Intention-revealing:** `Input(controller: c, placeholder: 'Email')` reads as +/// the artifact. It is a styled [EditableText] (the `widgets.dart` text-editing +/// primitive — `TextField` is Material and off-limits) wrapped in a single `.tw` +/// box. Fill/border/ring/text are all `flutterwindcss` semantic tokens (`input`, +/// `ring`, `destructive`, `foreground`, `mutedForeground`, `primary`), so a pasted +/// theme reskins it. Layout is faithful to shadcn v4 `h-9 w-full rounded-md border +/// border-input bg-transparent px-3 text-base`. +/// +/// **States** (faithful to shadcn): default `border-input`; focused +/// `border-ring` + a 3px `ring/50` focus ring; [invalid] `border-destructive` +/// (+ `destructive/20` ring when focused); disabled ([enabled] = false) +/// `opacity-50` + non-interactive. For a text field, focus == focus-visible (you +/// focus a field to type), so the ring shows on any focus. +/// +/// **Managed controller/focusNode.** Pass your own [controller]/[focusNode], or +/// omit them and the widget creates (and disposes) its own — the standard Flutter +/// pattern. +/// +/// **Block-level** (`w-full`): the field fills — and requires — a bounded-width +/// parent (place it in a constrained column/`SizedBox`/`Expanded`). Single-line +/// (`maxLines: 1`); a multi-line `Textarea` is a separate primitive. +/// +/// **Accessibility:** [EditableText] reports text-field semantics + the current +/// value; pass [semanticLabel] to name the field (e.g. "Email") — unlike Button's +/// `semanticLabel`, this does NOT replace the value (a screen reader announces +/// both the name and the typed text, which is correct for an input). +/// +/// **Faithful deviations (documented, not bugs):** `shadow-xs`, `md:text-sm`, and +/// `dark:bg-input/30` are dropped (an input's depth is its border+ring; 16px is +/// the mobile-first default; the dark fill needs a brightness-only token knob we +/// don't have — same precedent as Badge's dropped `dark:bg-destructive/60`). +/// Selected text keeps its `foreground` color (EditableText can't recolor text on +/// selection). **Not yet built (feasible, §11b):** a custom selection toolbar/ +/// handles (`TextSelectionControls`) — v1 ships keyboard + basic selection. +class Input extends StatefulWidget { + const Input({ + super.key, + this.controller, + this.focusNode, + this.placeholder, + this.enabled = true, + this.invalid = false, + this.obscureText = false, + this.keyboardType, + this.textInputAction, + this.onChanged, + this.onSubmitted, + this.autofocus = false, + this.semanticLabel, + }); + + /// External text controller. When null, the field manages its own. + final TextEditingController? controller; + + /// External focus control. When null, the field manages its own. + final FocusNode? focusNode; + + /// Hint shown in `mutedForeground` while the field is empty (shadcn + /// `placeholder:`). + final String? placeholder; + + /// Whether the field is interactive. `false` ⇒ `opacity-50`, read-only, and + /// non-focusable (shadcn `disabled:`). + final bool enabled; + + /// Whether to render the error treatment (shadcn `aria-invalid:` — a + /// `destructive` border, and a `destructive/20` ring when focused). The caller + /// owns validation; this is the visual flag. + final bool invalid; + + /// Obscures input (passwords). Maps to [EditableText.obscureText]. + final bool obscureText; + + /// The soft-keyboard type (e.g. `TextInputType.emailAddress`). + final TextInputType? keyboardType; + + /// The IME action button (e.g. `TextInputAction.next`). + final TextInputAction? textInputAction; + + /// Called on every edit with the new text. + final ValueChanged? onChanged; + + /// Called when the user submits (IME action / Enter). + final ValueChanged? onSubmitted; + + /// Whether to focus the field on first build. + final bool autofocus; + + /// Screen-reader name for the field (e.g. "Email"). Added alongside — not in + /// place of — the field's value. + final String? semanticLabel; + + @override + State createState() => _InputState(); +} + +class _InputState extends State { + TextEditingController? _internalController; + FocusNode? _internalFocusNode; + bool _focused = false; + + TextEditingController get _controller => + widget.controller ?? (_internalController ??= TextEditingController()); + FocusNode get _focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode()); + + @override + void initState() { + super.initState(); + _controller.addListener(_onChangedInternal); + _focusNode.addListener(_onFocusChanged); + } + + @override + void didUpdateWidget(Input old) { + super.didUpdateWidget(old); + // Re-subscribe if the caller swapped the controller/focusNode identity. + if (widget.controller != old.controller) { + (old.controller ?? _internalController)?.removeListener(_onChangedInternal); + if (old.controller != null && widget.controller == null) { + _internalController = TextEditingController(text: old.controller!.text); + } else if (widget.controller != null) { + _internalController?.dispose(); + _internalController = null; + } + _controller.addListener(_onChangedInternal); + } + if (widget.focusNode != old.focusNode) { + (old.focusNode ?? _internalFocusNode)?.removeListener(_onFocusChanged); + if (old.focusNode != null && widget.focusNode == null) { + _internalFocusNode = FocusNode(); + } else if (widget.focusNode != null) { + _internalFocusNode?.dispose(); + _internalFocusNode = null; + } + _focusNode.addListener(_onFocusChanged); + } + } + + @override + void dispose() { + (widget.controller ?? _internalController)?.removeListener(_onChangedInternal); + (widget.focusNode ?? _internalFocusNode)?.removeListener(_onFocusChanged); + _internalController?.dispose(); + _internalFocusNode?.dispose(); + super.dispose(); + } + + void _onChangedInternal() { + // Rebuild to toggle the placeholder as the text empties/fills. + if (mounted) setState(() {}); + } + + void _onFocusChanged() { + if (mounted) setState(() => _focused = _focusNode.hasFocus); + } + + @override + Widget build(BuildContext context) { + final c = context.fw.colors; + final focused = _focused && widget.enabled; + + final Color borderColor = + widget.invalid ? c.destructive : (focused ? c.ring : c.input); + final Color? ringColor = focused + ? (widget.invalid ? c.destructive : c.ring).withValues(alpha: widget.invalid ? 0.2 : 0.5) + : null; + + final textStyle = TextStyle(fontSize: FwFontSize.base.px, color: c.foreground); + + final editable = EditableText( + controller: _controller, + focusNode: _focusNode, + readOnly: !widget.enabled, + obscureText: widget.obscureText, + keyboardType: + widget.keyboardType ?? + (widget.obscureText ? TextInputType.visiblePassword : TextInputType.text), + textInputAction: widget.textInputAction, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + autofocus: widget.autofocus, + maxLines: 1, + style: textStyle, + cursorColor: c.foreground, + backgroundCursorColor: c.mutedForeground, + selectionColor: c.primary.withValues(alpha: 0.3), + cursorWidth: 2, + ); + + // Placeholder overlay (behind the field; shown only when empty). The field is + // the topmost child so taps reach it; both are start-aligned and same size. + final showPlaceholder = widget.placeholder != null && _controller.text.isEmpty; + final content = Stack( + alignment: AlignmentDirectional.centerStart, + children: [ + if (showPlaceholder) + Text( + widget.placeholder!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: FwFontSize.base.px, color: c.mutedForeground), + ), + editable, + ], + ); + + Widget box = content.tw + .bg(const Color(0x00000000)) + .border(1, color: borderColor) + .roundedMd + .px(3) + .py(2) + .minH(9) + .wFull; + if (ringColor != null) box = box.ring(3, color: ringColor); + if (!widget.enabled) box = box.opacity(0.5); + + Widget result = widget.enabled ? box : IgnorePointer(child: box); + if (widget.semanticLabel != null) { + result = Semantics(textField: true, label: widget.semanticLabel, child: result); + } + return result; + } +} +``` + +--- + +### Task 1: `Input` renders an `EditableText` that accepts text + +**Files:** Create `apps/gallery/lib/components/ui/input.dart`, `apps/gallery/test/input_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/input.dart'; + +/// Theme + width frame. Input is block-level (w-full), so it needs a bounded +/// width — a 280px box stands in for a real form 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: 280, child: child), + ), + ), + ), + ), +); + +void main() { + testWidgets('renders an EditableText and accepts typed text', (t) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, Input(controller: controller))); + expect(t.takeException(), isNull); + expect(find.byType(EditableText), findsOneWidget); + await t.enterText(find.byType(EditableText), 'hello'); + await t.pump(); + expect(controller.text, 'hello'); + }); +} +``` + +- [ ] **Step 2: Run → FAIL** (`Input` undefined): `cd apps/gallery && flutter test test/input_behavior_test.dart` +- [ ] **Step 3: Implement** — create `input.dart` with the **complete component above** (it satisfies this and the following tasks). Confirm the `EditableText` required params compile (`controller`, `focusNode`, `style`, `cursorColor`, `backgroundCursorColor`); fix any signature mismatch against the installed Flutter SDK and **note it in the report**. +- [ ] **Step 4: Run → PASS.** +- [ ] **Step 5: Commit** — `git add apps/gallery/lib/components/ui/input.dart apps/gallery/test/input_behavior_test.dart && git commit -m "feat(input): Input primitive — Material-free EditableText field (text entry)"` + +--- + +### Task 2: Placeholder shows when empty, hides when typing + +- [ ] **Step 1: Append test** + +```dart + testWidgets('placeholder shows when empty and disappears once text is entered', (t) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Input(controller: controller, placeholder: 'Email'), + ), + ); + expect(find.text('Email'), findsOneWidget); + await t.enterText(find.byType(EditableText), 'a@b.com'); + await t.pump(); + expect(find.text('Email'), findsNothing); + }); +``` + +- [ ] **Step 2: Run → PASS** (the complete impl already toggles the placeholder via the controller listener). If it fails, the controller listener / `setState` wiring is wrong — fix `_onChangedInternal`. +- [ ] **Step 3: Commit** — `git add -A apps/gallery/test/input_behavior_test.dart && git commit -m "test(input): placeholder toggles with empty/non-empty value"` + +--- + +### Task 3: Border token per state (default `input`, focused `ring`, invalid `destructive`) + +- [ ] **Step 1: Append tests** + +```dart + Border? _borderOf(WidgetTester t) { + final dec = t + .widgetList(find.byType(DecoratedBox)) + .map((d) => d.decoration) + .whereType() + .firstWhere((d) => d.border != null, orElse: () => const BoxDecoration()); + return dec.border as Border?; + } + + testWidgets('default border uses the input token', (t) async { + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input())); + expect(_borderOf(t)?.top.color, FwTokens.light.colors.input); + }); + + testWidgets('invalid border uses the destructive token', (t) async { + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input(invalid: true))); + expect(_borderOf(t)?.top.color, FwTokens.light.colors.destructive); + }); +``` + +- [ ] **Step 2: Run → PASS.** If the helper finds the wrong `DecoratedBox`, narrow it (the Input's box border is in the `input`/`destructive` token; assert that specific color). +- [ ] **Step 3: Commit** — `git add -A apps/gallery/test/input_behavior_test.dart && git commit -m "test(input): border token per state (input / destructive)"` + +--- + +### Task 4: Focus ring appears on focus + +- [ ] **Step 1: Append test** + +```dart + testWidgets('focusing the field shows a ring and recolors the border to ring', (t) async { + final focus = FocusNode(); + addTearDown(focus.dispose); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, Input(focusNode: focus))); + expect(_borderOf(t)?.top.color, FwTokens.light.colors.input); + focus.requestFocus(); + await t.pump(); + // Border recolors to the ring token… + expect(_borderOf(t)?.top.color, FwTokens.light.colors.ring); + // …and a boxShadow (the engine's ring) now exists. + final hasRing = t + .widgetList(find.byType(DecoratedBox)) + .map((d) => d.decoration) + .whereType() + .any((d) => (d.boxShadow?.isNotEmpty ?? false)); + expect(hasRing, isTrue, reason: 'focus must add a ring (boxShadow)'); + }); +``` + +- [ ] **Step 2: Run → PASS.** (Verify the engine's `.ring(...)` renders as a `boxShadow`; if it uses a different mechanism, assert that mechanism instead — do not weaken "a ring appears on focus".) +- [ ] **Step 3: Commit** — `git add -A apps/gallery/test/input_behavior_test.dart && git commit -m "test(input): focus shows ring + border-ring"` + +--- + +### Task 5: Disabled + obscure + callbacks + semantics + +- [ ] **Step 1: Append tests** + +```dart + testWidgets('disabled is read-only, non-interactive, and dimmed', (t) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, Input(controller: controller, enabled: false)), + ); + expect(find.byType(IgnorePointer), findsWidgets); + expect(t.widget(find.byType(EditableText)).readOnly, isTrue); + // Opacity wrapper present (opacity-50). + final hasDim = t.widgetList(find.byType(Opacity)).any((o) => o.opacity == 0.5); + expect(hasDim, isTrue); + }); + + testWidgets('obscureText is forwarded to the EditableText', (t) async { + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, const Input(obscureText: true)), + ); + expect(t.widget(find.byType(EditableText)).obscureText, isTrue); + }); + + testWidgets('onChanged / onSubmitted fire', (t) async { + String? changed; + String? submitted; + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Input(onChanged: (v) => changed = v, onSubmitted: (v) => submitted = v), + ), + ); + await t.enterText(find.byType(EditableText), 'hi'); + expect(changed, 'hi'); + await t.testTextInput.receiveAction(TextInputAction.done); + await t.pump(); + expect(submitted, 'hi'); + }); + + testWidgets('semanticLabel names the field without hiding its value', (t) async { + final handle = t.ensureSemantics(); + final controller = TextEditingController(text: 'typed'); + addTearDown(controller.dispose); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Input(controller: controller, semanticLabel: 'Email'), + ), + ); + expect(find.bySemanticsLabel('Email'), findsOneWidget); + handle.dispose(); + }); +``` + +- [ ] **Step 2: Run → PASS.** If `.opacity(0.5)` doesn't materialize as an `Opacity` widget (engine may use a different node), assert the engine's actual opacity mechanism; do not weaken the disabled assertion. If `receiveAction` needs the field focused first, `focus.requestFocus()` (give it a focusNode). +- [ ] **Step 3: Commit** — `git add -A apps/gallery/test/input_behavior_test.dart && git commit -m "test(input): disabled / obscure / callbacks / semantic label"` + +--- + +### Task 6: Managed controller/focusNode lifecycle (no leak, survives swap) + +- [ ] **Step 1: Append tests** + +```dart + testWidgets('manages its own controller when none is given (no crash, accepts text)', (t) async { + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input(placeholder: 'P'))); + await t.enterText(find.byType(EditableText), 'self-managed'); + await t.pump(); + expect(find.text('P'), findsNothing); // placeholder hidden ⇒ internal controller updated + // Tear-down disposes the internal controller without throwing. + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const SizedBox())); + expect(t.takeException(), isNull); + }); + + testWidgets('swapping an external controller in/out does not crash', (t) async { + final external = TextEditingController(text: 'X'); + addTearDown(external.dispose); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input())); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, Input(controller: external))); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Input())); + expect(t.takeException(), isNull); + }); +``` + +- [ ] **Step 2: Run → PASS.** A crash here means the `didUpdateWidget`/`dispose` re-subscription logic is wrong — fix it (do not delete the test). This is the highest-risk area; if it's flaky, simplify the lifecycle but keep "no external controller is ever disposed by Input". +- [ ] **Step 3: Commit** — `git add -A apps/gallery/test/input_behavior_test.dart && git commit -m "test(input): managed controller/focusNode lifecycle (swap + dispose safe)"` + +--- + +### Task 7: RTL + theme reskin + +- [ ] **Step 1: Append tests** + +```dart + testWidgets('renders under RTL with no overflow/exception', (t) async { + await t.binding.setSurfaceSize(const Size(360, 200)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.rtl, const Input(placeholder: 'بريد إلكتروني')), + ); + expect(t.takeException(), isNull); + expect(find.text('بريد إلكتروني'), findsOneWidget); + }); + + testWidgets('reskins with the active theme (dark input token border)', (t) async { + await t.pumpWidget(_frame(FwTokens.dark, TextDirection.ltr, const Input())); + final border = t + .widgetList(find.byType(DecoratedBox)) + .map((d) => d.decoration) + .whereType() + .firstWhere((d) => d.border != null, orElse: () => const BoxDecoration()) + .border as Border?; + expect(border?.top.color, FwTokens.dark.colors.input); + }); +``` + +- [ ] **Step 2: Run the FULL behavior suite → all PASS:** `cd apps/gallery && flutter test test/input_behavior_test.dart`. A `RenderFlex`/overflow in RTL is a real bug — fix the layout. +- [ ] **Step 3: analyze + format** + - `cd apps/gallery && flutter analyze --fatal-infos --fatal-warnings` → `No issues found!` + - `dart format --line-length 100 apps/gallery/lib/components/ui/input.dart apps/gallery/test/input_behavior_test.dart` → re-commit if changed. +- [ ] **Step 4: Commit** — `git add -A apps/gallery/test/input_behavior_test.dart apps/gallery/lib/components/ui/input.dart && git commit -m "test(input): RTL + theme-reskin coverage"` + +--- + +### Task 8: Golden grid (light / dark / RTL) + +- [ ] **Step 1: Create `apps/gallery/test/input_golden_test.dart`** + +```dart +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; +import 'package:flutterbits_gallery/components/ui/input.dart'; + +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() { + final focusNode = FocusNode()..requestFocus(); + return RepaintBoundary( + key: const ValueKey('input_grid'), + child: SizedBox( + width: 280, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Input(placeholder: 'Placeholder'), + const SizedBox(height: 12), + Input(controller: TextEditingController(text: 'Typed value')), + const SizedBox(height: 12), + // Focused (the requested-focus node drives the ring). + Input(focusNode: focusNode, controller: TextEditingController(text: 'Focused')), + const SizedBox(height: 12), + const Input(invalid: true, placeholder: 'Invalid'), + const SizedBox(height: 12), + const Input(enabled: false, placeholder: 'Disabled'), + ], + ), + ), + ); +} + +void main() { + const surfaceSize = Size(320, 420); + + testWidgets('input 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 t.pump(); + await expectLater( + find.byKey(const ValueKey('input_grid')), + matchesGoldenFile('goldens/input_grid_light.png'), + ); + }); + + testWidgets('input 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 t.pump(); + await expectLater( + find.byKey(const ValueKey('input_grid')), + matchesGoldenFile('goldens/input_grid_dark.png'), + ); + }); + + testWidgets('input grid — light RTL', (t) async { + await t.binding.setSurfaceSize(surfaceSize); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.rtl, _grid())); + await t.pump(); + await expectLater( + find.byKey(const ValueKey('input_grid')), + matchesGoldenFile('goldens/input_grid_rtl.png'), + ); + }); +} +``` + +> Note: the focused row may show a blinking cursor. If the golden is flaky on the cursor phase, pump a fixed duration so the cursor is in a deterministic state, or set `showCursor: false` is NOT available — instead, in the golden grid only, prefer an unfocused set and assert the ring separately in behavior tests. **Preferred:** keep the focused row but `await t.pump(const Duration(milliseconds: 10))` after pumping the frame so the cursor opacity is settled; if CI still flakes on the cursor, drop the focused row from the golden (the ring is already locked by the Task 4 behavior test) and record that in the commit. + +- [ ] **Step 2: Run → FAIL** (no goldens). `cd apps/gallery && flutter test test/input_golden_test.dart` +- [ ] **Step 3: `flutter test --update-goldens test/input_golden_test.dart`**, then **Read** the three PNGs and eyeball: 5 rows (placeholder grey, typed value, focused with ring+ring-border, invalid red border, disabled dimmed); RTL mirrors text start-edge. +- [ ] **Step 4: Run → PASS locally.** +- [ ] **Step 5: Commit** — `git add apps/gallery/test/input_golden_test.dart apps/gallery/test/goldens/input_grid_light.png apps/gallery/test/goldens/input_grid_dark.png apps/gallery/test/goldens/input_grid_rtl.png && git commit -m "test(input): golden grid (light/dark/RTL) — provisional, CI Linux authoritative"` + +--- + +### Task 9: Gallery integration + smoke + charter note + +- [ ] **Step 1: Smoke test (fail first)** — in `apps/gallery/test/gallery_smoke_test.dart`, after the `find.text('Badges')` assertion add: + +```dart + expect(find.text('Inputs'), findsWidgets); +``` + +- [ ] **Step 2: Run → FAIL.** `cd apps/gallery && flutter test test/gallery_smoke_test.dart` +- [ ] **Step 3: Gallery** — add `import 'components/ui/input.dart';` after the badge import in `apps/gallery/lib/main.dart`, and insert an Inputs section as the last children of the `home:` `Column` (before its closing `],`), mirroring the Badges block: + +```dart + const SizedBox(height: 24), + Text('Inputs').tw.textSize(20).weight(FwFontWeight.bold), + const SizedBox(height: 12), + const SizedBox( + width: 280, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Input(placeholder: 'Email'), + SizedBox(height: 12), + Input(invalid: true, placeholder: 'Invalid'), + SizedBox(height: 12), + Input(enabled: false, placeholder: 'Disabled'), + ], + ), + ), +``` + +- [ ] **Step 4: Run smoke + analyze** — both green (`flutter test test/gallery_smoke_test.dart`; `flutter analyze --fatal-infos --fatal-warnings`). +- [ ] **Step 5: Charter note** — in `docs/superpowers/specs/2026-06-10-flutterbits-charter.md` §8 "Sequencing update (2026-06-15)" paragraph, append after the `Badge` sentence: + +```markdown + `Input` (2026-06-15) follows as the form-cluster keystone — a Material-free `EditableText` field (placeholder, focus ring, invalid/disabled states; see `apps/gallery/lib/components/ui/input.dart`). +``` + +(leading space to join the paragraph; keep existing text). Then verify `git grep -n "coming soon" apps/docs/content/docs/flutterbits` still matches (docs tab stays overview-only — no drift). + +- [ ] **Step 6: Commit** — `git add apps/gallery/lib/main.dart apps/gallery/test/gallery_smoke_test.dart docs/superpowers/specs/2026-06-10-flutterbits-charter.md && git commit -m "feat(gallery): showcase Input states; smoke-test; charter progress note"` + +--- + +### Task 10: Final verification + PR + CI golden re-baseline + +- [ ] **Step 1: Whole-gallery analyze** — `cd apps/gallery && flutter analyze --fatal-infos --fatal-warnings` → `No issues found!` +- [ ] **Step 2: Format check** — `dart format --line-length 100 --set-exit-if-changed apps/gallery/lib/components/ui/input.dart apps/gallery/test/input_behavior_test.dart apps/gallery/test/input_golden_test.dart apps/gallery/lib/main.dart apps/gallery/test/gallery_smoke_test.dart` → no changes. +- [ ] **Step 3: Full suite** — `cd apps/gallery && flutter test` → input behavior + input golden + smoke pass locally (button/card/badge goldens may diff on Windows AA — not gating). +- [ ] **Step 4: PR** — `git push -u origin feat/flutterbits-input` then `gh pr create --title "feat(input): Input primitive (shadcn v4) + gallery showcase" --body-file `. Body: summary, shadcn class mapping, the documented deviations (shadow-xs/md:text-sm/dark:bg-input/30 dropped; selection text color; no custom selection toolbar — feasible/not-yet-built), the managed-controller note, test coverage, provisional-goldens note. End with the Claude Code trailer. +- [ ] **Step 5: CI golden re-baseline (if needed)** — wait for the `gallery` job. If its golden step fails on the Linux renders: `gh run download -n gallery-golden-failures -D `, copy each `input_grid_*_testImage.png` over its baseline (strip `_testImage`), eyeball, commit `test(input): re-baseline goldens on CI Linux (authoritative platform)`, push. (Badge needed none; be ready in case Input's text/AA differs.) +- [ ] **Step 6: Merge** — `gh pr checks` green → `gh pr merge --merge --delete-branch` → `git checkout main && git pull`. + +--- + +## Self-review (against the grounding) + +- **Spec coverage:** every shadcn Input class mapped or explicitly dropped-with-reason (shadow-xs, md:text-sm, dark:bg-input/30, file:*, selection text color, selection toolbar). States default/focus/invalid/disabled all implemented + tested. Text entry, placeholder, obscure, keyboardType, callbacks, semantics all covered. +- **AGENTS.md rules:** semantic tokens only (the one literal is the §3.1-allowed transparent `Color(0x00000000)`; ring/selection colors are token-derived via `withValues`, not literals); directional (`AlignmentDirectional`, `px`/`py`, `wFull`, `Semantics`); `widgets.dart` + `services.dart` only (no Material — passes the arch-guard); `.tw` single-box styling; one component file; golden coverage (states × brightness × RTL); rendered in `apps/gallery`. No enums needed (Input has no variant/size) — so no exhaustive-switch requirement. +- **Type consistency:** `Input({controller, focusNode, placeholder, enabled, invalid, obscureText, keyboardType, textInputAction, onChanged, onSubmitted, autofocus, semanticLabel})` used identically across impl, tests, gallery, goldens. +- **Placeholder scan:** none — full component source + full test code + exact commands given. +- **Deviation honesty:** the dropped utilities and the not-yet-built selection toolbar are recorded in the class doc and PR with the §11b "feasible, not built" framing; the managed-controller lifecycle (the highest-risk area) has a dedicated regression task (Task 6). No capability is lowered (a Textarea/selection-toolbar are additive future work, not removed promises). +``` diff --git a/docs/superpowers/specs/2026-06-10-flutterbits-charter.md b/docs/superpowers/specs/2026-06-10-flutterbits-charter.md index ab7186b..8d722d8 100644 --- a/docs/superpowers/specs/2026-06-10-flutterbits-charter.md +++ b/docs/superpowers/specs/2026-06-10-flutterbits-charter.md @@ -167,7 +167,7 @@ This charter is the umbrella. Implementation is decomposed into specs, each its **First vertical slice (proves the whole stack end-to-end):** `Layout` + `Screen` + routing + `Button` + `ThemeToggle`, rendered in **`apps/gallery`** — a **new** flutterbits component showcase + golden/compile target, created with this slice and kept separate from the engine's `apps/example` (decision 2026-06-10). `ThemeToggle` is the chosen first concrete component — tiny, pure "feel good," and it forces every layer to play together (`Layout` owning theme → the `Switch` primitive → semantic-token reskin → the engine's `FwAnimatedTheme` transition). -> **Sequencing update (2026-06-15):** after `Button` proved the component-authoring stack end-to-end (PRs #45–#47 — `apps/gallery` scaffold, CI gating, canonical audit), the **primitives catalog (§3.2) is being built out before the structure layer (item 2 above)**. Rationale: it is lower-risk, reuses the proven component pattern, and several structure pieces depend on primitives anyway (e.g. `ThemeToggle` → a `Switch` primitive). `Card` (2026-06-15) is the first post-`Button` primitive and the canonical template for **compound, non-interactive** components (composed subcomponents, slot-based regions; see `apps/gallery/lib/components/ui/card.dart`). `Badge` (2026-06-15) follows as a **variant-based, non-interactive single-box** primitive (`Button`'s variant discipline without its state machine; see `apps/gallery/lib/components/ui/badge.dart`). The structure-and-routing spec remains next-in-line as an epic — this is a **sequencing** change, not a scope change. +> **Sequencing update (2026-06-15):** after `Button` proved the component-authoring stack end-to-end (PRs #45–#47 — `apps/gallery` scaffold, CI gating, canonical audit), the **primitives catalog (§3.2) is being built out before the structure layer (item 2 above)**. Rationale: it is lower-risk, reuses the proven component pattern, and several structure pieces depend on primitives anyway (e.g. `ThemeToggle` → a `Switch` primitive). `Card` (2026-06-15) is the first post-`Button` primitive and the canonical template for **compound, non-interactive** components (composed subcomponents, slot-based regions; see `apps/gallery/lib/components/ui/card.dart`). `Badge` (2026-06-15) follows as a **variant-based, non-interactive single-box** primitive (`Button`'s variant discipline without its state machine; see `apps/gallery/lib/components/ui/badge.dart`). `Input` (2026-06-15) follows as the form-cluster keystone — a Material-free `EditableText` field (placeholder, focus ring, invalid/disabled states; see `apps/gallery/lib/components/ui/input.dart`). The structure-and-routing spec remains next-in-line as an epic — this is a **sequencing** change, not a scope change. ---