From ef1f2912e5cc50504dc782e800b4a72b32a98ef4 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 14:02:52 +0200 Subject: [PATCH 1/6] feat(input): Input primitive -- Material-free EditableText field (text entry) --- apps/gallery/lib/components/ui/input.dart | 226 +++++++++++++++++++++ apps/gallery/test/input_behavior_test.dart | 179 ++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 apps/gallery/lib/components/ui/input.dart create mode 100644 apps/gallery/test/input_behavior_test.dart diff --git a/apps/gallery/lib/components/ui/input.dart b/apps/gallery/lib/components/ui/input.dart new file mode 100644 index 0000000..5471878 --- /dev/null +++ b/apps/gallery/lib/components/ui/input.dart @@ -0,0 +1,226 @@ +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, + }); + + /// 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); + 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() { + 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, + ); + + 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 = widget.enabled ? box : IgnorePointer(child: box); + if (widget.semanticLabel != null) { + result = Semantics(textField: true, label: widget.semanticLabel, child: result); + } + return result; + } +} diff --git a/apps/gallery/test/input_behavior_test.dart b/apps/gallery/test/input_behavior_test.dart new file mode 100644 index 0000000..1a00b78 --- /dev/null +++ b/apps/gallery/test/input_behavior_test.dart @@ -0,0 +1,179 @@ +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(); + 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)), + ); + expect(find.byType(IgnorePointer), findsWidgets); + 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('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); + 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); + }); + + // ---- 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); + }); +} From 0653ccd4f264894a031e0cdfba990bf10aca39a5 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 14:08:52 +0200 Subject: [PATCH 2/6] test(input): placeholder + border tokens + focus ring --- apps/gallery/lib/components/ui/input.dart | 32 ++++++++++++---------- apps/gallery/test/input_behavior_test.dart | 16 +++++++---- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/apps/gallery/lib/components/ui/input.dart b/apps/gallery/lib/components/ui/input.dart index 5471878..5ad34fe 100644 --- a/apps/gallery/lib/components/ui/input.dart +++ b/apps/gallery/lib/components/ui/input.dart @@ -104,8 +104,6 @@ class Input extends StatefulWidget { 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()); @@ -156,18 +154,21 @@ class _InputState extends State { } void _onFocusChanged() { - if (mounted) setState(() => _focused = _focusNode.hasFocus); + if (mounted) setState(() {}); } @override Widget build(BuildContext context) { final c = context.fw.colors; - final focused = _focused && widget.enabled; + 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 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); @@ -206,14 +207,15 @@ class _InputState extends State { ], ); - FwStyled box = content.tw - .bg(const Color(0x00000000)) - .border(1, color: borderColor) - .roundedMd - .px(3) - .py(2) - .minH(9) - .wFull; + 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); diff --git a/apps/gallery/test/input_behavior_test.dart b/apps/gallery/test/input_behavior_test.dart index 1a00b78..e237cb4 100644 --- a/apps/gallery/test/input_behavior_test.dart +++ b/apps/gallery/test/input_behavior_test.dart @@ -50,7 +50,11 @@ void main() { final controller = TextEditingController(); addTearDown(controller.dispose); await t.pumpWidget( - _frame(FwTokens.light, TextDirection.ltr, Input(controller: controller, placeholder: 'Email')), + _frame( + FwTokens.light, + TextDirection.ltr, + Input(controller: controller, placeholder: 'Email'), + ), ); expect(find.text('Email'), findsOneWidget); await t.enterText(find.byType(EditableText), 'a@b.com'); @@ -71,6 +75,10 @@ void main() { 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 @@ -162,11 +170,7 @@ void main() { await t.binding.setSurfaceSize(const Size(360, 200)); addTearDown(() => t.binding.setSurfaceSize(null)); await t.pumpWidget( - _frame( - FwTokens.light, - TextDirection.rtl, - const Input(placeholder: 'بريد إلكتروني'), - ), + _frame(FwTokens.light, TextDirection.rtl, const Input(placeholder: 'بريد إلكتروني')), ); expect(t.takeException(), isNull); expect(find.text('بريد إلكتروني'), findsOneWidget); From 83df27df0e8fc588e5405cc5bd3a9e12a5c6c570 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 14:23:49 +0200 Subject: [PATCH 3/6] fix(input): disabled non-focusable (ExcludeFocus), merge label+value semantics, perf+test guards (review) --- apps/gallery/lib/components/ui/input.dart | 27 ++++++++++++++++++---- apps/gallery/test/input_behavior_test.dart | 25 +++++++++++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/gallery/lib/components/ui/input.dart b/apps/gallery/lib/components/ui/input.dart index 5ad34fe..11c061d 100644 --- a/apps/gallery/lib/components/ui/input.dart +++ b/apps/gallery/lib/components/ui/input.dart @@ -54,7 +54,10 @@ class Input extends StatefulWidget { 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; @@ -150,7 +153,9 @@ class _InputState extends State { } void _onChangedInternal() { - if (mounted) setState(() {}); + // 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() { @@ -188,6 +193,8 @@ class _InputState extends State { 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, ); @@ -219,9 +226,21 @@ class _InputState extends State { 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); + 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) { - result = Semantics(textField: true, label: widget.semanticLabel, child: result); + // 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/test/input_behavior_test.dart b/apps/gallery/test/input_behavior_test.dart index e237cb4..e63b72d 100644 --- a/apps/gallery/test/input_behavior_test.dart +++ b/apps/gallery/test/input_behavior_test.dart @@ -97,12 +97,28 @@ void main() { await t.pumpWidget( _frame(FwTokens.light, TextDirection.ltr, Input(controller: controller, enabled: false)), ); - expect(find.byType(IgnorePointer), findsWidgets); + // 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); @@ -139,6 +155,9 @@ void main() { ), ); 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(); }); @@ -162,6 +181,10 @@ void main() { 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 ---- From 5e3c6f3ee19f62fbba66040b83a91fdb50b57829 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 14:28:03 +0200 Subject: [PATCH 4/6] =?UTF-8?q?test(input):=20golden=20grid=20(light/dark/?= =?UTF-8?q?RTL)=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 Sonnet 4.6 --- apps/gallery/test/goldens/input_grid_dark.png | Bin 0 -> 2689 bytes .../gallery/test/goldens/input_grid_light.png | Bin 0 -> 3166 bytes apps/gallery/test/goldens/input_grid_rtl.png | Bin 0 -> 3166 bytes apps/gallery/test/input_golden_test.dart | 86 ++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 apps/gallery/test/goldens/input_grid_dark.png create mode 100644 apps/gallery/test/goldens/input_grid_light.png create mode 100644 apps/gallery/test/goldens/input_grid_rtl.png create mode 100644 apps/gallery/test/input_golden_test.dart 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 0000000000000000000000000000000000000000..a5387a05510e9a875b728b49d0d6783ef15acad6 GIT binary patch literal 2689 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 LaX46Ng-`q&k6O4$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..060be5ae1d546e0df38812d9a9ad92e4f8bdf4bc GIT binary patch literal 3166 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 K?yYhTJ^MRHB+d%} literal 0 HcmV?d00001 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'), + ); + }); +} From c3e22d048a44ed8f72b81655dde37532ae9cd0cd Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 14:28:11 +0200 Subject: [PATCH 5/6] feat(gallery): showcase Input states; smoke-test; charter progress note Co-Authored-By: Claude Sonnet 4.6 --- apps/gallery/lib/main.dart | 17 +++++++++++++++++ apps/gallery/test/gallery_smoke_test.dart | 1 + .../specs/2026-06-10-flutterbits-charter.md | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) 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/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. --- From e48a8551debb20945cff07759bd458b1a38d462b Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 14:31:28 +0200 Subject: [PATCH 6/6] docs(plan): Input primitive implementation plan --- .../plans/2026-06-15-flutterbits-input.md | 737 ++++++++++++++++++ 1 file changed, 737 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-flutterbits-input.md 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). +```