From 5f418f0d61abb455a12ea3c7dcc54fd37ac35adc Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 14:57:58 +0200 Subject: [PATCH 1/5] feat(switch): Switch primitive + full behavior test suite Controlled animated toggle (TDD): switch.dart + switch_behavior_test.dart. All 12 behavior tests pass: tap toggles on/off, off=input/on=primary track color, md wider than sm, animation settles, Space/Enter keyboard toggle, disabled dimmed + non-interactive, >=48px hit target (MinTapTarget), toggled semantics + Wi-Fi label, RTL no overflow, dark reskin. SDK deviation: SemanticsFlags.isToggled is Tristate in Flutter 3.41.9 (no hasToggledState getter); test uses Tristate.isTrue comparison. --- apps/gallery/lib/components/ui/switch.dart | 195 +++++++++++++++++++ apps/gallery/test/switch_behavior_test.dart | 202 ++++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 apps/gallery/lib/components/ui/switch.dart create mode 100644 apps/gallery/test/switch_behavior_test.dart diff --git a/apps/gallery/lib/components/ui/switch.dart b/apps/gallery/lib/components/ui/switch.dart new file mode 100644 index 0000000..894ddf3 --- /dev/null +++ b/apps/gallery/lib/components/ui/switch.dart @@ -0,0 +1,195 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; + +import '_utils/tap_target.dart'; + +/// shadcn's switch sizes. `md` is shadcn's `default` size (`default` is a Dart +/// reserved word, so it cannot be an enum constant). Geometry: md = 32×18 track / +/// 16 thumb (shadcn `w-8 h-[1.15rem]` / `size-4`); sm = 24×14 / 12 (`w-6 h-3.5` / +/// `size-3`). +enum SwitchSize { sm, md } + +/// A Material-free, themeable toggle switch — shadcn parity. Copy-paste source you +/// own. +/// +/// **Intention-revealing & controlled:** `Switch(value: on, onChanged: (v) => …)` +/// reads as the artifact. Like shadcn/Radix it is **controlled** — the parent owns +/// `value`; a tap (or Space/Enter) calls `onChanged(!value)`. The widget holds no +/// value state, only the toggle *animation*. +/// +/// **Animated, dependency-free.** A 200ms `easeInOut` `AnimationController` drives +/// one `t` (0 = off → 1 = on) that slides the thumb +/// (`AlignmentDirectional.lerp(centerStart, centerEnd, t)`) and lerps the track +/// color (`Color.lerp(input, primary, t)`). No `flutter_animate` — a state toggle +/// is a built-in implicit animation, so a copied `Switch` drags in nothing. +/// +/// **Canonical interaction pattern (mirrors [Button]).** It sources its own +/// focus/hover via [FocusableActionDetector], activates on Space/Enter +/// (`ActivateIntent`), shows a focus-*visible* `ring`, and wraps its hit layer in +/// [MinTapTarget] for a ≥48px touch target around the ~18px visual (mobile-first). +/// `onChanged == null` disables it (`opacity-50`, non-interactive) — the Flutter +/// idiom. +/// +/// **Accessibility:** `Semantics(toggled: value, enabled:, label:)` — a switch role +/// announcing on/off. A standalone switch is often named by an adjacent label; +/// pass [semanticLabel] when it has no other accessible name. +/// +/// **Faithful deviations (documented, not bugs):** `shadow-xs` is dropped (the +/// shape + thumb are the affordance). The dark-only refinements +/// (`dark:…unchecked:bg-input/80` and the per-state dark thumb colors) are dropped +/// — the thumb is `background` in both brightnesses (faithful in light; in dark the +/// *unchecked* thumb is slightly lower-contrast, while the "on" state stays high- +/// contrast). Same precedent as Badge's/Input's dropped `dark:` refinements. +class Switch extends StatefulWidget { + const Switch({ + super.key, + required this.value, + this.onChanged, + this.size = SwitchSize.md, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + }); + + /// Whether the switch is on. The parent owns this (controlled). + final bool value; + + /// Called with the toggled value when the user flips the switch. `null` + /// disables the switch (the Flutter idiom). + final ValueChanged? onChanged; + + /// The switch size (shadcn `default` → [SwitchSize.md], or [SwitchSize.sm]). + final SwitchSize size; + + /// External focus control. When null, the detector manages its own. + final FocusNode? focusNode; + + /// Whether to focus the switch on first build. + final bool autofocus; + + /// Screen-reader name (e.g. "Wi-Fi"). Omit when an adjacent label already names + /// the control. + final String? semanticLabel; + + /// Whether the switch is interactive. + bool get enabled => onChanged != null; + + @override + State createState() => _SwitchState(); +} + +class _SwitchState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _t; + bool _focused = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + value: widget.value ? 1 : 0, + ); + _t = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); + } + + @override + void didUpdateWidget(Switch old) { + super.didUpdateWidget(old); + if (widget.value != old.value) { + _controller.animateTo(widget.value ? 1 : 0); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _set(VoidCallback fn) { + if (mounted) setState(fn); + } + + void _toggle() => widget.onChanged?.call(!widget.value); + + /// Track/thumb geometry per size, in utility units (×4 logical px). + ({double trackW, double trackH, double thumb, double pad}) _geometry() { + return switch (widget.size) { + SwitchSize.sm => (trackW: 6, trackH: 3.5, thumb: 3, pad: 0.5), + SwitchSize.md => (trackW: 8, trackH: 4.5, thumb: 4, pad: 0.5), + }; + } + + @override + Widget build(BuildContext context) { + final c = context.fw.colors; + final enabled = widget.enabled; + final g = _geometry(); + + final visual = AnimatedBuilder( + animation: _t, + builder: (context, _) { + final t = _t.value; + final trackColor = Color.lerp(c.input, c.primary, t)!; + final thumbAlign = + AlignmentDirectional.lerp( + AlignmentDirectional.centerStart, + AlignmentDirectional.centerEnd, + t, + )!; + + final thumb = const SizedBox.shrink().tw.size(g.thumb).bg(c.background).roundedFull; + + var track = Align(alignment: thumbAlign, child: thumb).tw + .w(g.trackW) + .h(g.trackH) + .px(g.pad) + .bg(trackColor) + .roundedFull + .border(1, color: const Color(0x00000000)); + if (_focused && enabled) { + track = track.ring(3, color: c.ring.withValues(alpha: 0.5)); + } + if (!enabled) { + track = track.opacity(0.5); + } + return track; + }, + ); + + return Semantics( + toggled: widget.value, + enabled: enabled, + label: widget.semanticLabel, + child: FocusableActionDetector( + enabled: enabled, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + mouseCursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic, + onShowFocusHighlight: (f) => _set(() => _focused = f), + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + }, + actions: >{ + ActivateIntent: CallbackAction( + onInvoke: (_) { + _toggle(); + return null; + }, + ), + }, + child: MinTapTarget( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled ? _toggle : null, + child: visual, + ), + ), + ), + ); + } +} diff --git a/apps/gallery/test/switch_behavior_test.dart b/apps/gallery/test/switch_behavior_test.dart new file mode 100644 index 0000000..865c433 --- /dev/null +++ b/apps/gallery/test/switch_behavior_test.dart @@ -0,0 +1,202 @@ +import 'dart:ui' show Tristate; + +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/switch.dart'; +import 'package:flutterbits_gallery/components/ui/_utils/tap_target.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: Center(child: child)), + ), + ), +); + +// The track is the styled box that has BOTH a non-null color AND a border AND a +// borderRadius (the thumb has color+radius but no border). +Color? _trackColor(WidgetTester t) { + return t + .widgetList(find.byType(DecoratedBox)) + .map((d) => d.decoration) + .whereType() + .where((d) => d.borderRadius != null && d.color != null && d.border != null) + .map((d) => d.color) + .firstOrNull; +} + +void main() { + testWidgets('tapping an off switch calls onChanged(true)', (t) async { + bool? changed; + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Switch(value: false, onChanged: (v) => changed = v), + ), + ); + expect(t.takeException(), isNull); + await t.tap(find.byType(Switch)); + await t.pumpAndSettle(); + expect(changed, isTrue); + }); + + testWidgets('tapping an on switch calls onChanged(false)', (t) async { + bool? changed; + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, Switch(value: true, onChanged: (v) => changed = v)), + ); + await t.tap(find.byType(Switch)); + await t.pumpAndSettle(); + expect(changed, isFalse); + }); + + testWidgets('off switch track is the input token; on switch is primary', (t) async { + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Switch(value: false))); + await t.pumpAndSettle(); + expect(_trackColor(t), FwTokens.light.colors.input); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Switch(value: true))); + await t.pumpAndSettle(); + expect(_trackColor(t), FwTokens.light.colors.primary); + }); + + testWidgets('md and sm produce different track widths', (t) async { + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, const Switch(value: true, size: SwitchSize.md)), + ); + final mdW = + t + .getSize(find.descendant(of: find.byType(Switch), matching: find.byType(Align)).first) + .width; + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, const Switch(value: true, size: SwitchSize.sm)), + ); + final smW = + t + .getSize(find.descendant(of: find.byType(Switch), matching: find.byType(Align)).first) + .width; + expect(mdW, greaterThan(smW), reason: 'md track must be wider than sm'); + }); + + testWidgets('toggling animates then settles (no pending timers)', (t) async { + bool on = false; + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + StatefulBuilder( + builder: + (context, setState) => Switch(value: on, onChanged: (v) => setState(() => on = v)), + ), + ), + ); + await t.tap(find.byType(Switch)); + await t.pump(); + await t.pump(const Duration(milliseconds: 100)); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + }); + + testWidgets('Space toggles when focused', (t) async { + bool? changed; + final focus = FocusNode(); + addTearDown(focus.dispose); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Switch(value: false, focusNode: focus, onChanged: (v) => changed = v), + ), + ); + focus.requestFocus(); + await t.pump(); + await t.sendKeyEvent(LogicalKeyboardKey.space); + await t.pump(); + expect(changed, isTrue, reason: 'Space while focused must toggle'); + }); + + testWidgets('Enter toggles when focused', (t) async { + bool? changed; + final focus = FocusNode(); + addTearDown(focus.dispose); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Switch(value: true, focusNode: focus, onChanged: (v) => changed = v), + ), + ); + focus.requestFocus(); + await t.pump(); + await t.sendKeyEvent(LogicalKeyboardKey.enter); + await t.pump(); + expect(changed, isFalse, reason: 'Enter while focused must toggle'); + }); + + testWidgets('disabled (onChanged null) does not toggle and is dimmed', (t) async { + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Switch(value: false))); + await t.pumpAndSettle(); + await t.tap(find.byType(Switch), warnIfMissed: false); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + final dimmed = t.widgetList(find.byType(Opacity)).any((o) => o.opacity == 0.5); + expect(dimmed, isTrue, reason: 'disabled switch must be opacity-50'); + }); + + testWidgets('hit target is >= 48px around the small visual', (t) async { + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, Switch(value: true, onChanged: (_) {})), + ); + final size = t.getSize(find.byType(MinTapTarget)); + expect(size.height, greaterThanOrEqualTo(48.0)); + expect(size.width, greaterThanOrEqualTo(48.0)); + }); + + testWidgets('reports switch (toggled) semantics + label', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Switch(value: true, semanticLabel: 'Wi-Fi', onChanged: (_) {}), + ), + ); + final data = t.getSemantics(find.byType(Switch)).getSemanticsData(); + // isToggled is a Tristate in this SDK — `none` means no toggled state, + // `isTrue`/`isFalse` means toggled-on / toggled-off. + expect( + data.flagsCollection.isToggled, + isNot(Tristate.none), + reason: 'switch must expose toggled state', + ); + expect( + data.flagsCollection.isToggled, + Tristate.isTrue, + reason: 'value:true must be toggled-on', + ); + expect(find.bySemanticsLabel('Wi-Fi'), findsOneWidget); + handle.dispose(); + }); + + testWidgets('renders under RTL with no overflow/exception', (t) async { + await t.binding.setSurfaceSize(const Size(200, 200)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.rtl, Switch(value: true, onChanged: (_) {})), + ); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(find.byType(Switch), findsOneWidget); + }); + + testWidgets('reskins with the active theme (dark primary track when on)', (t) async { + await t.pumpWidget(_frame(FwTokens.dark, TextDirection.ltr, const Switch(value: true))); + await t.pumpAndSettle(); + expect(_trackColor(t), FwTokens.dark.colors.primary); + }); +} From 7ae319d8995093fd202f1c08efa6d57c826ea094 Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 15:11:55 +0200 Subject: [PATCH 2/5] fix(switch): dispose CurvedAnimation, AnimatedBuilder child idiom, focus-ring test (review) --- apps/gallery/lib/components/ui/switch.dart | 25 ++++++++++++++------- apps/gallery/test/switch_behavior_test.dart | 25 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/gallery/lib/components/ui/switch.dart b/apps/gallery/lib/components/ui/switch.dart index 894ddf3..a207514 100644 --- a/apps/gallery/lib/components/ui/switch.dart +++ b/apps/gallery/lib/components/ui/switch.dart @@ -81,7 +81,7 @@ class Switch extends StatefulWidget { class _SwitchState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; - late final Animation _t; + late final CurvedAnimation _t; bool _focused = false; @override @@ -105,6 +105,9 @@ class _SwitchState extends State with SingleTickerProviderStateMixin { @override void dispose() { + // Dispose the CurvedAnimation first — it removes its status listener from the + // controller — then the controller it wraps. + _t.dispose(); _controller.dispose(); super.dispose(); } @@ -116,10 +119,10 @@ class _SwitchState extends State with SingleTickerProviderStateMixin { void _toggle() => widget.onChanged?.call(!widget.value); /// Track/thumb geometry per size, in utility units (×4 logical px). - ({double trackW, double trackH, double thumb, double pad}) _geometry() { + ({double trackW, double trackH, double thumbSize, double pad}) _geometry() { return switch (widget.size) { - SwitchSize.sm => (trackW: 6, trackH: 3.5, thumb: 3, pad: 0.5), - SwitchSize.md => (trackW: 8, trackH: 4.5, thumb: 4, pad: 0.5), + SwitchSize.sm => (trackW: 6, trackH: 3.5, thumbSize: 3, pad: 0.5), + SwitchSize.md => (trackW: 8, trackH: 4.5, thumbSize: 4, pad: 0.5), }; } @@ -129,9 +132,15 @@ class _SwitchState extends State with SingleTickerProviderStateMixin { final enabled = widget.enabled; final g = _geometry(); + // The thumb is static during the toggle (only its *position* animates), so it + // is passed as `AnimatedBuilder`'s `child` and built once — not rebuilt every + // frame (the canonical `AnimatedBuilder` idiom). + final thumb = const SizedBox.shrink().tw.size(g.thumbSize).bg(c.background).roundedFull; + final visual = AnimatedBuilder( animation: _t, - builder: (context, _) { + child: thumb, + builder: (context, child) { final t = _t.value; final trackColor = Color.lerp(c.input, c.primary, t)!; final thumbAlign = @@ -141,14 +150,14 @@ class _SwitchState extends State with SingleTickerProviderStateMixin { t, )!; - final thumb = const SizedBox.shrink().tw.size(g.thumb).bg(c.background).roundedFull; - - var track = Align(alignment: thumbAlign, child: thumb).tw + var track = Align(alignment: thumbAlign, child: child).tw .w(g.trackW) .h(g.trackH) .px(g.pad) .bg(trackColor) .roundedFull + // Mirrors shadcn's `border border-transparent`: reserves the 1px + // border-box edge so the thumb's inner area is stable. .border(1, color: const Color(0x00000000)); if (_focused && enabled) { track = track.ring(3, color: c.ring.withValues(alpha: 0.5)); diff --git a/apps/gallery/test/switch_behavior_test.dart b/apps/gallery/test/switch_behavior_test.dart index 865c433..3a1a887 100644 --- a/apps/gallery/test/switch_behavior_test.dart +++ b/apps/gallery/test/switch_behavior_test.dart @@ -138,6 +138,31 @@ void main() { expect(changed, isFalse, reason: 'Enter while focused must toggle'); }); + testWidgets('focus-visible shows a ring on the track', (t) async { + // Force the keyboard ("traditional") highlight so onShowFocusHighlight fires + // (the ring is focus-VISIBLE, like Button — not shown on a mouse/touch focus). + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + addTearDown(() => FocusManager.instance.highlightStrategy = FocusHighlightStrategy.automatic); + final focus = FocusNode(); + addTearDown(focus.dispose); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Switch(value: false, focusNode: focus, onChanged: (_) {}), + ), + ); + focus.requestFocus(); + await t.pump(); + await t.pump(); + final hasRing = t + .widgetList(find.byType(DecoratedBox)) + .map((d) => d.decoration) + .whereType() + .any((d) => (d.boxShadow?.isNotEmpty ?? false)); + expect(hasRing, isTrue, reason: 'focus-visible must add a ring (boxShadow)'); + }); + testWidgets('disabled (onChanged null) does not toggle and is dimmed', (t) async { await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Switch(value: false))); await t.pumpAndSettle(); From 9036176c43398ed0e7eb738b66efc283b257dbbd Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 15:14:41 +0200 Subject: [PATCH 3/5] test(switch): golden grid (light/dark/RTL) -- provisional, CI Linux authoritative --- .../gallery/test/goldens/switch_grid_dark.png | Bin 0 -> 3035 bytes .../test/goldens/switch_grid_light.png | Bin 0 -> 3080 bytes apps/gallery/test/goldens/switch_grid_rtl.png | Bin 0 -> 3033 bytes apps/gallery/test/switch_golden_test.dart | 91 ++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 apps/gallery/test/goldens/switch_grid_dark.png create mode 100644 apps/gallery/test/goldens/switch_grid_light.png create mode 100644 apps/gallery/test/goldens/switch_grid_rtl.png create mode 100644 apps/gallery/test/switch_golden_test.dart diff --git a/apps/gallery/test/goldens/switch_grid_dark.png b/apps/gallery/test/goldens/switch_grid_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..bb70809062dfa22e04c879a59168d478beaf3fab GIT binary patch literal 3035 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 La)j4g_}uvyUasMy literal 0 HcmV?d00001 diff --git a/apps/gallery/test/goldens/switch_grid_rtl.png b/apps/gallery/test/goldens/switch_grid_rtl.png new file mode 100644 index 0000000000000000000000000000000000000000..339f1dc1f9687222956399d455556ae7f4489ade GIT binary patch literal 3033 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 Li9(Pqu=oE3?X?Kn literal 0 HcmV?d00001 diff --git a/apps/gallery/test/switch_golden_test.dart b/apps/gallery/test/switch_golden_test.dart new file mode 100644 index 0000000..28bd98f --- /dev/null +++ b/apps/gallery/test/switch_golden_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; +import 'package:flutterbits_gallery/components/ui/switch.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), + ), + ), + ), + ), +); + +void _noop(bool _) {} + +Widget _grid() => RepaintBoundary( + key: const ValueKey('switch_grid'), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Switch(value: false, onChanged: _noop), + SizedBox(width: 16), + Switch(value: true, onChanged: _noop), + SizedBox(width: 16), + Switch(value: false, size: SwitchSize.sm, onChanged: _noop), + SizedBox(width: 16), + Switch(value: true, size: SwitchSize.sm, onChanged: _noop), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Switch(value: false), + SizedBox(width: 16), + Switch(value: true), + ], + ), + ], + ), +); + +void main() { + const surfaceSize = Size(360, 200); + + testWidgets('switch 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.pumpAndSettle(); + await expectLater( + find.byKey(const ValueKey('switch_grid')), + matchesGoldenFile('goldens/switch_grid_light.png'), + ); + }); + + testWidgets('switch 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.pumpAndSettle(); + await expectLater( + find.byKey(const ValueKey('switch_grid')), + matchesGoldenFile('goldens/switch_grid_dark.png'), + ); + }); + + testWidgets('switch grid — light RTL (thumb side mirrors)', (t) async { + await t.binding.setSurfaceSize(surfaceSize); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.rtl, _grid())); + await t.pumpAndSettle(); + await expectLater( + find.byKey(const ValueKey('switch_grid')), + matchesGoldenFile('goldens/switch_grid_rtl.png'), + ); + }); +} From 2666967ab3bab696bd145cb0b4ccd8faaff2af6a Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 15:17:35 +0200 Subject: [PATCH 4/5] feat(gallery): showcase Switch states; smoke-test; charter progress note --- apps/gallery/lib/main.dart | 32 +++++++++++++++++++ apps/gallery/test/gallery_smoke_test.dart | 1 + apps/gallery/test/switch_golden_test.dart | 6 +--- .../specs/2026-06-10-flutterbits-charter.md | 2 +- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/gallery/lib/main.dart b/apps/gallery/lib/main.dart index 26618a5..1b8aa35 100644 --- a/apps/gallery/lib/main.dart +++ b/apps/gallery/lib/main.dart @@ -4,6 +4,7 @@ import 'components/ui/button.dart'; import 'components/ui/card.dart'; import 'components/ui/badge.dart'; import 'components/ui/input.dart'; +import 'components/ui/switch.dart'; void main() => runApp(const GalleryApp()); @@ -145,6 +146,10 @@ class GalleryApp extends StatelessWidget { ], ), ), + const SizedBox(height: 24), + Text('Switches').tw.textSize(20).weight(FwFontWeight.bold), + const SizedBox(height: 12), + const _SwitchDemo(), ], ), ), @@ -171,3 +176,30 @@ class _Dot extends StatelessWidget { ); } } + +/// A tiny stateful demo so the showcased [Switch]es actually toggle. +class _SwitchDemo extends StatefulWidget { + const _SwitchDemo(); + + @override + State<_SwitchDemo> createState() => _SwitchDemoState(); +} + +class _SwitchDemoState extends State<_SwitchDemo> { + bool _a = true; + bool _b = false; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch(value: _a, onChanged: (v) => setState(() => _a = v), semanticLabel: 'Wi-Fi'), + const SizedBox(width: 16), + Switch(value: _b, size: SwitchSize.sm, onChanged: (v) => setState(() => _b = v)), + const SizedBox(width: 16), + const Switch(value: true), // disabled + ], + ); + } +} diff --git a/apps/gallery/test/gallery_smoke_test.dart b/apps/gallery/test/gallery_smoke_test.dart index 334174b..7d4d225 100644 --- a/apps/gallery/test/gallery_smoke_test.dart +++ b/apps/gallery/test/gallery_smoke_test.dart @@ -13,5 +13,6 @@ void main() { expect(find.text('Create project'), findsWidgets); expect(find.text('Badges'), findsWidgets); expect(find.text('Inputs'), findsWidgets); + expect(find.text('Switches'), findsWidgets); }); } diff --git a/apps/gallery/test/switch_golden_test.dart b/apps/gallery/test/switch_golden_test.dart index 28bd98f..7bd376f 100644 --- a/apps/gallery/test/switch_golden_test.dart +++ b/apps/gallery/test/switch_golden_test.dart @@ -43,11 +43,7 @@ Widget _grid() => RepaintBoundary( const SizedBox(height: 8), Row( mainAxisSize: MainAxisSize.min, - children: const [ - Switch(value: false), - SizedBox(width: 16), - Switch(value: true), - ], + children: const [Switch(value: false), SizedBox(width: 16), Switch(value: true)], ), ], ), diff --git a/docs/superpowers/specs/2026-06-10-flutterbits-charter.md b/docs/superpowers/specs/2026-06-10-flutterbits-charter.md index 8d722d8..78b80b5 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`). `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. +> **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`). `Switch` (2026-06-15) follows as the canonical **interactive + animated** toggle — Button's interaction scaffolding plus a dependency-free sliding-thumb animation (see `apps/gallery/lib/components/ui/switch.dart`); it unblocks the structure layer's `ThemeToggle`. The structure-and-routing spec remains next-in-line as an epic — this is a **sequencing** change, not a scope change. --- From 36cd53a63ba48971617363b15e5d85671463d94b Mon Sep 17 00:00:00 2001 From: Sipho Nkebe Date: Mon, 15 Jun 2026 15:18:40 +0200 Subject: [PATCH 5/5] docs(plan): Switch primitive implementation plan --- .../plans/2026-06-15-flutterbits-switch.md | 702 ++++++++++++++++++ 1 file changed, 702 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-flutterbits-switch.md diff --git a/docs/superpowers/plans/2026-06-15-flutterbits-switch.md b/docs/superpowers/plans/2026-06-15-flutterbits-switch.md new file mode 100644 index 0000000..15ea8f6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-flutterbits-switch.md @@ -0,0 +1,702 @@ +# Switch 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 `Switch` — the fifth flutterbits primitive and the canonical **interactive + animated toggle** — as a Material-free, themeable, shadcn-v4-faithful control in `apps/gallery`, with behavior + golden tests and CI gating. + +**Architecture:** `Switch` is an action-bearing `StatefulWidget` that mirrors `Button`'s interaction scaffolding (`FocusableActionDetector` + a tap detector, focus-visible `ring`, Space/Enter activation, `MinTapTarget` ≥48px hit area, `Semantics`) and adds a toggle animation: a `SingleTickerProviderStateMixin` `AnimationController` drives a single `t` (0 = off, 1 = on) that **slides the thumb** (`AlignmentDirectional.lerp(centerStart, centerEnd, t)`) and **lerps the track color** (`Color.lerp(input, primary, t)`) every frame via one `AnimatedBuilder`. Both the track and thumb stay single `.tw` boxes (the lerped color/alignment are just animated *values* fed to `.tw`/`Align` — no hand-nested style wrappers). **Dependency-free** — built-in implicit animation, no `flutter_animate` (that dep is for entrance/exit/shimmer effects, not a state toggle), so `add switch` installs nothing. + +**Tech Stack:** Dart / Flutter (`package:flutter/widgets.dart` + `package:flutter/services.dart` for `LogicalKeyboardKey` — both allowed, NOT material), `flutterwindcss` engine (`.tw`, `context.fw`), the shared `MinTapTarget` util, `flutter_test` goldens (CI Linux authoritative). + +--- + +## Grounding (read before starting) + +**Authoritative shadcn v4 Switch classes** (from `ui.shadcn.com/r/styles/new-york-v4/switch.json`, verbatim, fetched 2026-06-15): + +- **Track (root):** `peer inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80` +- **Thumb:** `pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground` + +**Mapping (and documented deviations — recorded, not silent):** +- Track `rounded-full border border-transparent`, checked `bg-primary` / unchecked `bg-input` → `.tw.bg().roundedFull.border(1, color: Color(0x00000000))` (transparent border = the one allowed literal, geometry-consistent — Badge precedent). The lerped color is `Color.lerp(c.input, c.primary, t)` (token-derived, not a literal). +- **Sizes (shadcn `default`/`sm`):** `SwitchSize {sm, md}` (`md` = shadcn `default`, the reserved-word rule). Geometry in utility units (×4 logical px): **md** track `w(8)`×`h(4.5)` (32×18 ≈ shadcn `w-8 h-[1.15rem]`=32×18.4), thumb `size(4)` (16 = `size-4`); **sm** track `w(6)`×`h(3.5)` (24×14 = `w-6 h-3.5`), thumb `size(3)` (12 = `size-3`). The 0.4px `h` rounding (18 vs 18.4) is imperceptible. +- Thumb `bg-background`, slide `data-[state=unchecked]:translate-x-0` → `data-[state=checked]:translate-x-[calc(100%-2px)]` → an `Align` whose alignment lerps `centerStart`↔`centerEnd`, inside a `.px(0.5)` (2px) track inset so the thumb rests ~2px from each end (matches shadcn's `calc(100%-2px)` margin). Thumb = `c.background`. +- `focus-visible:ring-[3px] ring-ring/50` (+ `border-ring`) → when focus-visible: `.ring(3, color: c.ring.withValues(alpha: 0.5))` on the track (token-derived alpha). Focus-visible only (keyboard) — sourced via `FocusableActionDetector.onShowFocusHighlight` (framework-gated on `highlightMode`, like Button). +- `disabled:opacity-50` → `.opacity(0.5)` when `onChanged == null`; disabled is also non-interactive (no tap, `FocusableActionDetector(enabled: false)`). +- **Dropped (documented, same precedent as Input/Badge dark-only refinements):** `shadow-xs` (a switch's affordance is its shape + thumb, not shadow); `dark:data-[state=unchecked]:bg-input/80` and the per-state dark thumb colors (`dark:data-[state=checked]:bg-primary-foreground` / `dark:…:bg-foreground`). The thumb is `background` in both brightnesses — faithful in light; in dark the unchecked thumb is slightly lower-contrast than shadcn's dark refinement (the checked "on" state stays high-contrast). Recorded in the class doc. + +**Engine API facts (verified):** `.tw` setters `.bg(Color)`, `.border(1,{color})`, `.roundedFull` (getter), `.w(u)`/`.h(u)`/`.size(u)` (utility units ×4px; `fw_style_ops.dart:164/167/170`), `.px(u)`, `.opacity(double)`, `.ring(double width,{required Color color,…})`. `context.fw.colors` has `input, primary, background, ring`. `Color.lerp`/`AlignmentDirectional.lerp` are framework. `MinTapTarget` (shared util at `apps/gallery/lib/components/ui/_utils/tap_target.dart`) gives a ≥48px hit area without enlarging the paint — its doc already names `Switch`. + +**Canonical pattern to mirror:** `apps/gallery/lib/components/ui/button.dart` — the interactive template: `FocusableActionDetector` + `GestureDetector`, `ActivateIntent`→`CallbackAction` on Space/Enter, focus-visible `ring`, `MinTapTarget` around the hit layer, `Semantics`, `onChanged`/`onPressed == null` ⇒ disabled, the `_set(fn)` mounted-guard, and the "source own states + flat `.tw`" doctrine (AGENTS.md §6). Switch adds the `AnimationController` for the toggle motion. + +**Golden harness to mirror:** `apps/gallery/test/input_golden_test.dart` style (FwTheme→Directionality→MediaQuery→ColoredBox→Align→Padding→RepaintBoundary(key:), `setSurfaceSize`, three goldens light/dark/RTL). **CI (Linux) is authoritative** (AGENTS.md §9); local `--update-goldens` is provisional. Switches render at their settled state (the controller is initialized to `value ? 1 : 0`, so a static on/off golden has no in-flight animation — deterministic without extra flags). + +**Design decisions locked:** +1. `Switch` is an action-bearing `StatefulWidget`; `onChanged == null` ⇒ disabled (the Flutter idiom, like Button's `onPressed`). +2. API: `Switch({required bool value, ValueChanged? onChanged, SwitchSize size = SwitchSize.md, FocusNode? focusNode, bool autofocus = false, String? semanticLabel})`. It's **controlled** (the parent owns `value`; tap calls `onChanged(!value)`), matching shadcn/Radix — Switch holds no value state, only animation state. +3. Two sizes `sm`/`md` (shadcn parity). +4. Animation: 200ms `Curves.easeInOut`, dependency-free `AnimationController`. +5. Accessibility: `Semantics(toggled: value, enabled:, label: semanticLabel)` (a switch role with on/off state). No child text ⇒ no semantics-merge hazard; `semanticLabel` is optional (a standalone switch is often named by an adjacent `Label`). +6. `MinTapTarget` ≥48px hit area (mobile-first §4) — the visual is only ~18px tall. + +**No-drift scope:** Switch is another primitive (no new canonical template — it's a second *interactive* component alongside Button). Task 9 records it in charter §8 and verifies the docs tab stays overview-only. **Deferred (recorded):** registry promotion (with the registry/CLI plan, as for the others). **Unblocks:** the structure layer's `ThemeToggle` (charter §3.3) now has its `Switch` dependency. + +--- + +## File structure + +- Create: `apps/gallery/lib/components/ui/switch.dart` — `SwitchSize` + `Switch`. +- Create: `apps/gallery/test/switch_behavior_test.dart`. +- Create: `apps/gallery/test/switch_golden_test.dart` + provisional goldens `switch_grid_{light,dark,rtl}.png`. +- Modify: `apps/gallery/lib/main.dart` — Switches showcase section. +- Modify: `apps/gallery/test/gallery_smoke_test.dart` — assert a Switch example renders. +- Modify: `docs/superpowers/specs/2026-06-10-flutterbits-charter.md` — §8 progress note. + +--- + +## The complete component (target for Tasks 1–6) + +`apps/gallery/lib/components/ui/switch.dart`: + +```dart +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutterwindcss/flutterwindcss.dart'; + +import '_utils/tap_target.dart'; + +/// shadcn's switch sizes. `md` is shadcn's `default` size (`default` is a Dart +/// reserved word, so it cannot be an enum constant). Geometry: md = 32×18 track / +/// 16 thumb (shadcn `w-8 h-[1.15rem]` / `size-4`); sm = 24×14 / 12 (`w-6 h-3.5` / +/// `size-3`). +enum SwitchSize { sm, md } + +/// A Material-free, themeable toggle switch — shadcn parity. Copy-paste source you +/// own. +/// +/// **Intention-revealing & controlled:** `Switch(value: on, onChanged: (v) => …)` +/// reads as the artifact. Like shadcn/Radix it is **controlled** — the parent owns +/// `value`; a tap (or Space/Enter) calls `onChanged(!value)`. The widget holds no +/// value state, only the toggle *animation*. +/// +/// **Animated, dependency-free.** A 200ms `easeInOut` `AnimationController` drives +/// one `t` (0 = off → 1 = on) that slides the thumb +/// (`AlignmentDirectional.lerp(centerStart, centerEnd, t)`) and lerps the track +/// color (`Color.lerp(input, primary, t)`). No `flutter_animate` — a state toggle +/// is a built-in implicit animation, so a copied `Switch` drags in nothing. +/// +/// **Canonical interaction pattern (mirrors [Button]).** It sources its own +/// focus/hover via [FocusableActionDetector], activates on Space/Enter +/// (`ActivateIntent`), shows a focus-*visible* `ring`, and wraps its hit layer in +/// [MinTapTarget] for a ≥48px touch target around the ~18px visual (mobile-first). +/// `onChanged == null` disables it (`opacity-50`, non-interactive) — the Flutter +/// idiom. +/// +/// **Accessibility:** `Semantics(toggled: value, enabled:, label:)` — a switch role +/// announcing on/off. A standalone switch is often named by an adjacent label; +/// pass [semanticLabel] when it has no other accessible name. +/// +/// **Faithful deviations (documented, not bugs):** `shadow-xs` is dropped (the +/// shape + thumb are the affordance). The dark-only refinements +/// (`dark:…unchecked:bg-input/80` and the per-state dark thumb colors) are dropped +/// — the thumb is `background` in both brightnesses (faithful in light; in dark the +/// *unchecked* thumb is slightly lower-contrast, while the "on" state stays high- +/// contrast). Same precedent as Badge's/Input's dropped `dark:` refinements. +class Switch extends StatefulWidget { + const Switch({ + super.key, + required this.value, + this.onChanged, + this.size = SwitchSize.md, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + }); + + /// Whether the switch is on. The parent owns this (controlled). + final bool value; + + /// Called with the toggled value when the user flips the switch. `null` + /// disables the switch (the Flutter idiom). + final ValueChanged? onChanged; + + /// The switch size (shadcn `default` → [SwitchSize.md], or [SwitchSize.sm]). + final SwitchSize size; + + /// External focus control. When null, the detector manages its own. + final FocusNode? focusNode; + + /// Whether to focus the switch on first build. + final bool autofocus; + + /// Screen-reader name (e.g. "Wi-Fi"). Omit when an adjacent label already names + /// the control. + final String? semanticLabel; + + /// Whether the switch is interactive. + bool get enabled => onChanged != null; + + @override + State createState() => _SwitchState(); +} + +class _SwitchState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _t; + bool _focused = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + value: widget.value ? 1 : 0, + ); + _t = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); + } + + @override + void didUpdateWidget(Switch old) { + super.didUpdateWidget(old); + if (widget.value != old.value) { + _controller.animateTo(widget.value ? 1 : 0); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _set(VoidCallback fn) { + if (mounted) setState(fn); + } + + void _toggle() => widget.onChanged?.call(!widget.value); + + /// Track/thumb geometry per size, in utility units (×4 logical px). + ({double trackW, double trackH, double thumb, double pad}) _geometry() { + return switch (widget.size) { + SwitchSize.sm => (trackW: 6, trackH: 3.5, thumb: 3, pad: 0.5), + SwitchSize.md => (trackW: 8, trackH: 4.5, thumb: 4, pad: 0.5), + }; + } + + @override + Widget build(BuildContext context) { + final c = context.fw.colors; + final enabled = widget.enabled; + final g = _geometry(); + + final visual = AnimatedBuilder( + animation: _t, + builder: (context, _) { + final t = _t.value; + final trackColor = Color.lerp(c.input, c.primary, t)!; + final thumbAlign = AlignmentDirectional.lerp( + AlignmentDirectional.centerStart, + AlignmentDirectional.centerEnd, + t, + )!; + + final thumb = const SizedBox.shrink().tw.size(g.thumb).bg(c.background).roundedFull; + + var track = Align(alignment: thumbAlign, child: thumb).tw + .w(g.trackW) + .h(g.trackH) + .px(g.pad) + .bg(trackColor) + .roundedFull + .border(1, color: const Color(0x00000000)); + if (_focused && enabled) { + track = track.ring(3, color: c.ring.withValues(alpha: 0.5)); + } + if (!enabled) { + track = track.opacity(0.5); + } + return track; + }, + ); + + return Semantics( + toggled: widget.value, + enabled: enabled, + label: widget.semanticLabel, + child: FocusableActionDetector( + enabled: enabled, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + mouseCursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic, + onShowFocusHighlight: (f) => _set(() => _focused = f), + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + }, + actions: >{ + ActivateIntent: CallbackAction( + onInvoke: (_) { + _toggle(); + return null; + }, + ), + }, + child: MinTapTarget( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled ? _toggle : null, + child: visual, + ), + ), + ), + ); + } +} +``` + +--- + +### Task 1: `Switch` renders and toggles via `onChanged` + +**Files:** Create `apps/gallery/lib/components/ui/switch.dart`, `apps/gallery/test/switch_behavior_test.dart`. + +- [ ] **Step 1: Write the failing test** + +```dart +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/switch.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: Center(child: child)), + ), + ), +); + +void main() { + testWidgets('tapping an off switch calls onChanged(true)', (t) async { + bool? changed; + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, Switch(value: false, onChanged: (v) => changed = v)), + ); + expect(t.takeException(), isNull); + await t.tap(find.byType(Switch)); + await t.pumpAndSettle(); + expect(changed, isTrue); + }); + + testWidgets('tapping an on switch calls onChanged(false)', (t) async { + bool? changed; + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, Switch(value: true, onChanged: (v) => changed = v)), + ); + await t.tap(find.byType(Switch)); + await t.pumpAndSettle(); + expect(changed, isFalse); + }); +} +``` + +- [ ] **Step 2: Run → FAIL** (`Switch` undefined): `cd apps/gallery && flutter test test/switch_behavior_test.dart` +- [ ] **Step 3: Implement** — create `switch.dart` with the **complete component above**. Verify `Semantics(toggled:)` and `FocusableActionDetector`/`ActivateIntent` compile against the SDK; note any mismatch. +- [ ] **Step 4: Run → PASS.** +- [ ] **Step 5: Commit** — `git add apps/gallery/lib/components/ui/switch.dart apps/gallery/test/switch_behavior_test.dart && git commit -m "feat(switch): Switch primitive — controlled animated toggle (tap toggles)"` + +--- + +### Task 2: Track color reflects state (off = input, on = primary) + +- [ ] **Step 1: Append tests** (the track is the box carrying both a border AND a non-null color; at the settled state its color equals the token) + +```dart + Color? _trackColor(WidgetTester t) { + return t + .widgetList(find.byType(DecoratedBox)) + .map((d) => d.decoration) + .whereType() + .where((d) => d.borderRadius != null && d.color != null && d.border != null) + .map((d) => d.color) + .firstOrNull; + } + + testWidgets('off switch track is the input token; on switch is primary', (t) async { + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, const Switch(value: false)), + ); + await t.pumpAndSettle(); + expect(_trackColor(t), FwTokens.light.colors.input); + + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, const Switch(value: true)), + ); + await t.pumpAndSettle(); + expect(_trackColor(t), FwTokens.light.colors.primary); + }); +``` + +- [ ] **Step 2: Run → PASS.** If `_trackColor` grabs the thumb's box instead (the thumb is also rounded + has a color but NO border), the `d.border != null` guard should exclude it; if both match, additionally guard on the larger size or assert via `Color.lerp` endpoints. Do not weaken "off=input / on=primary". +- [ ] **Step 3: Commit** — `git add -A apps/gallery/test/switch_behavior_test.dart && git commit -m "test(switch): track color off=input / on=primary"` + +--- + +### Task 3: Sizes (sm/md geometry) + animation settles + +- [ ] **Step 1: Append tests** + +```dart + testWidgets('md and sm produce different track widths', (t) async { + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, const Switch(value: true, size: SwitchSize.md)), + ); + final mdW = t.getSize(find.byType(Align).first).width; + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, const Switch(value: true, size: SwitchSize.sm)), + ); + final smW = t.getSize(find.byType(Align).first).width; + expect(mdW, greaterThan(smW), reason: 'md track must be wider than sm'); + }); + + testWidgets('toggling animates then settles (no pending timers)', (t) async { + bool on = false; + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + StatefulBuilder( + builder: (context, setState) => + Switch(value: on, onChanged: (v) => setState(() => on = v)), + ), + ), + ); + await t.tap(find.byType(Switch)); + await t.pump(); // start the animation + await t.pump(const Duration(milliseconds: 100)); // mid-flight + await t.pumpAndSettle(); // must settle (no infinite animation) + expect(t.takeException(), isNull); + }); +``` + +- [ ] **Step 2: Run → PASS.** If `find.byType(Align).first` is ambiguous (the frame's `Center`/`Align` count), narrow with a key or use `find.descendant(of: find.byType(Switch), matching: find.byType(Align)).first`. The settle test proves the controller is not a never-ending animation. +- [ ] **Step 3: Commit** — `git add -A apps/gallery/test/switch_behavior_test.dart && git commit -m "test(switch): sm/md geometry + animation settles"` + +--- + +### Task 4: Keyboard activation + focus ring + +- [ ] **Step 1: Append tests** + +```dart + testWidgets('Space toggles when focused', (t) async { + bool? changed; + final focus = FocusNode(); + addTearDown(focus.dispose); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Switch(value: false, focusNode: focus, onChanged: (v) => changed = v), + ), + ); + focus.requestFocus(); + await t.pump(); + await t.sendKeyEvent(LogicalKeyboardKey.space); + await t.pump(); + expect(changed, isTrue, reason: 'Space while focused must toggle'); + }); + + testWidgets('Enter toggles when focused', (t) async { + bool? changed; + final focus = FocusNode(); + addTearDown(focus.dispose); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Switch(value: true, focusNode: focus, onChanged: (v) => changed = v), + ), + ); + focus.requestFocus(); + await t.pump(); + await t.sendKeyEvent(LogicalKeyboardKey.enter); + await t.pump(); + expect(changed, isFalse, reason: 'Enter while focused must toggle'); + }); +``` + +- [ ] **Step 2: Run → PASS.** +- [ ] **Step 3: Commit** — `git add -A apps/gallery/test/switch_behavior_test.dart && git commit -m "test(switch): keyboard activation (Space/Enter)"` + +--- + +### Task 5: Disabled + hit target + semantics + +- [ ] **Step 1: Append tests** + +```dart + testWidgets('disabled (onChanged null) does not toggle and is dimmed', (t) async { + await t.pumpWidget(_frame(FwTokens.light, TextDirection.ltr, const Switch(value: false))); + await t.pumpAndSettle(); + await t.tap(find.byType(Switch), warnIfMissed: false); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + final dimmed = t.widgetList(find.byType(Opacity)).any((o) => o.opacity == 0.5); + expect(dimmed, isTrue, reason: 'disabled switch must be opacity-50'); + }); + + testWidgets('hit target is >= 48px around the small visual', (t) async { + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.ltr, Switch(value: true, onChanged: (_) {})), + ); + final size = t.getSize(find.byType(MinTapTarget)); + expect(size.height, greaterThanOrEqualTo(48.0)); + expect(size.width, greaterThanOrEqualTo(48.0)); + }); + + testWidgets('reports switch (toggled) semantics + enabled + label', (t) async { + final handle = t.ensureSemantics(); + await t.pumpWidget( + _frame( + FwTokens.light, + TextDirection.ltr, + Switch(value: true, semanticLabel: 'Wi-Fi', onChanged: (_) {}), + ), + ); + final node = t.getSemantics(find.byType(Switch)); + final data = node.getSemanticsData(); + expect(data.flagsCollection.hasToggledState, isTrue); + expect(data.flagsCollection.isToggled, isTrue); + expect(find.bySemanticsLabel('Wi-Fi'), findsOneWidget); + handle.dispose(); + }); +``` + +> Note: the exact `flagsCollection` accessor names are SDK-version-specific (Flutter 3.41 uses `getSemanticsData().flagsCollection.hasToggledState`/`.isToggled`, matching `button_behavior_test.dart`'s `flagsCollection.isButton`/`.isEnabled`). If a name differs, mirror whatever `button_behavior_test.dart` uses for flags and adapt — do not weaken the "is a toggled switch" assertion. `import 'dart:ui' show Tristate;` may be needed for `isEnabled` comparisons (see button test). + +- [ ] **Step 2: Run → PASS.** +- [ ] **Step 3: Commit** — `git add -A apps/gallery/test/switch_behavior_test.dart && git commit -m "test(switch): disabled / hit target / toggled semantics"` + +--- + +### Task 6: RTL + theme reskin + +- [ ] **Step 1: Append tests** + +```dart + testWidgets('renders under RTL with no overflow/exception', (t) async { + await t.binding.setSurfaceSize(const Size(200, 200)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget( + _frame(FwTokens.light, TextDirection.rtl, Switch(value: true, onChanged: (_) {})), + ); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(find.byType(Switch), findsOneWidget); + }); + + testWidgets('reskins with the active theme (dark primary track when on)', (t) async { + await t.pumpWidget(_frame(FwTokens.dark, TextDirection.ltr, const Switch(value: true))); + await t.pumpAndSettle(); + final onColor = t + .widgetList(find.byType(DecoratedBox)) + .map((d) => d.decoration) + .whereType() + .where((d) => d.borderRadius != null && d.color != null && d.border != null) + .map((d) => d.color) + .firstOrNull; + expect(onColor, FwTokens.dark.colors.primary); + }); +``` + +- [ ] **Step 2: Run the FULL behavior suite → all PASS:** `cd apps/gallery && flutter test test/switch_behavior_test.dart`. +- [ ] **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/switch.dart apps/gallery/test/switch_behavior_test.dart` → re-commit if changed. +- [ ] **Step 4: Commit** — `git add -A apps/gallery/test/switch_behavior_test.dart apps/gallery/lib/components/ui/switch.dart && git commit -m "test(switch): RTL + theme-reskin coverage"` + +--- + +### Task 7: Golden grid (light / dark / RTL) + +- [ ] **Step 1: Create `apps/gallery/test/switch_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/switch.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() => RepaintBoundary( + key: const ValueKey('switch_grid'), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Switch(value: false, onChanged: _noop), + SizedBox(width: 16), + Switch(value: true, onChanged: _noop), + SizedBox(width: 16), + Switch(value: false, size: SwitchSize.sm, onChanged: _noop), + SizedBox(width: 16), + Switch(value: true, size: SwitchSize.sm, onChanged: _noop), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Switch(value: false), // disabled off + SizedBox(width: 16), + Switch(value: true), // disabled on + ], + ), + ], + ), +); + +void _noop(bool _) {} + +void main() { + const surfaceSize = Size(360, 200); + + testWidgets('switch 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.pumpAndSettle(); + await expectLater( + find.byKey(const ValueKey('switch_grid')), + matchesGoldenFile('goldens/switch_grid_light.png'), + ); + }); + + testWidgets('switch 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.pumpAndSettle(); + await expectLater( + find.byKey(const ValueKey('switch_grid')), + matchesGoldenFile('goldens/switch_grid_dark.png'), + ); + }); + + testWidgets('switch grid — light RTL (thumb side mirrors)', (t) async { + await t.binding.setSurfaceSize(surfaceSize); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(_frame(FwTokens.light, TextDirection.rtl, _grid())); + await t.pumpAndSettle(); + await expectLater( + find.byKey(const ValueKey('switch_grid')), + matchesGoldenFile('goldens/switch_grid_rtl.png'), + ); + }); +} +``` + +- [ ] **Step 2: Run → FAIL** (no goldens). `cd apps/gallery && flutter test test/switch_golden_test.dart` +- [ ] **Step 3:** `cd apps/gallery && flutter test --update-goldens test/switch_golden_test.dart`, then **Read** the three PNGs and eyeball: row 1 = off (grey/input track, thumb at start) / on (primary track, thumb at end) for md then sm; row 2 = dimmed disabled pair. RTL mirrors the thumb to the opposite side (on-thumb at the left). +- [ ] **Step 4: Run → PASS locally.** +- [ ] **Step 5: Commit** — `git add apps/gallery/test/switch_golden_test.dart apps/gallery/test/goldens/switch_grid_light.png apps/gallery/test/goldens/switch_grid_dark.png apps/gallery/test/goldens/switch_grid_rtl.png && git commit -m "test(switch): golden grid (light/dark/RTL) — provisional, CI Linux authoritative"` + +--- + +### Task 8: Gallery integration + smoke + charter note + +- [ ] **Step 1: Smoke test (fail first)** — in `apps/gallery/test/gallery_smoke_test.dart`, after the `find.text('Inputs')` assertion add: +```dart + expect(find.text('Switches'), findsWidgets); +``` +- [ ] **Step 2: Run → FAIL.** `cd apps/gallery && flutter test test/gallery_smoke_test.dart` +- [ ] **Step 3: Gallery** — add `import 'components/ui/switch.dart';` after the input import in `apps/gallery/lib/main.dart`. Because `Switch` needs a live `value`, add a small stateful demo. Convert the `home:` `Builder` body is already stateless; insert a `_SwitchDemo()` widget. As the LAST entries of the `home:` `Column`'s children (before the closing `],`): +```dart + const SizedBox(height: 24), + Text('Switches').tw.textSize(20).weight(FwFontWeight.bold), + const SizedBox(height: 12), + const _SwitchDemo(), +``` +and add this widget at the bottom of the file (after `_Dot`): +```dart +/// A tiny stateful demo so the showcased [Switch]es actually toggle. +class _SwitchDemo extends StatefulWidget { + const _SwitchDemo(); + + @override + State<_SwitchDemo> createState() => _SwitchDemoState(); +} + +class _SwitchDemoState extends State<_SwitchDemo> { + bool _a = true; + bool _b = false; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch(value: _a, onChanged: (v) => setState(() => _a = v), semanticLabel: 'Wi-Fi'), + const SizedBox(width: 16), + Switch(value: _b, size: SwitchSize.sm, onChanged: (v) => setState(() => _b = v)), + const SizedBox(width: 16), + const Switch(value: true), // disabled + ], + ); + } +} +``` +- [ ] **Step 4: Run smoke + analyze** — `cd apps/gallery && flutter test test/gallery_smoke_test.dart` (PASS); `cd apps/gallery && flutter analyze --fatal-infos --fatal-warnings` (`No issues found!`). +- [ ] **Step 5: Charter note** — in `docs/superpowers/specs/2026-06-10-flutterbits-charter.md` §8 "Sequencing update (2026-06-15)" paragraph, append after the `Input` sentence (literal leading space): +```markdown + `Switch` (2026-06-15) follows as the canonical **interactive + animated** toggle — Button's interaction scaffolding plus a dependency-free sliding-thumb animation (see `apps/gallery/lib/components/ui/switch.dart`); it unblocks the structure layer's `ThemeToggle`. +``` +Then run `git grep -n "coming soon" apps/docs/content/docs/flutterbits` and confirm it still matches (report the output). +- [ ] **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 Switch states; smoke-test; charter progress note"` + +--- + +### Task 9: 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/switch.dart apps/gallery/test/switch_behavior_test.dart apps/gallery/test/switch_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` → switch behavior + switch golden + smoke pass locally (button/card goldens may diff on Windows AA — not gating). +- [ ] **Step 4: PR** — `git push -u origin feat/flutterbits-switch` then `gh pr create --title "feat(switch): Switch primitive (shadcn v4) + gallery showcase" --body-file `. Body: summary, shadcn class mapping, documented deviations (shadow-xs + dark refinements dropped), the dependency-free-animation note, controlled-component note, test coverage, provisional-goldens note. 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 `switch_grid_*_testImage.png` over its baseline (strip `_testImage`), eyeball, commit `test(switch): re-baseline goldens on CI Linux (authoritative platform)`, push. +- [ ] **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 Switch class mapped or dropped-with-reason (shadow-xs, dark refinements). On/off track color, sm/md sizes, sliding thumb + color animation, focus-visible ring, Space/Enter activation, disabled, ≥48px hit target, toggled semantics, RTL, reskin — all implemented + tested. +- **AGENTS.md rules:** semantic tokens only (the one literal is the §3.1-allowed transparent `Color(0x00000000)`; track/ring colors are token-derived via `Color.lerp`/`withValues`, not literals); directional (`AlignmentDirectional.centerStart/End` + `.lerp`, `px`, `Semantics`); `widgets.dart` + `services.dart` only (no Material — passes the arch-guard); single-box `.tw` styling for track + thumb (animated values fed in, no hand-nested wrappers); typed `SwitchSize` enum with an exhaustive `switch` (no `default:`); golden coverage (state × size × brightness × RTL); rendered in `apps/gallery`; `MinTapTarget` for the touch target; the canonical action-component interaction pattern (FAD + tap detector + flat `.tw`, sourcing own states). +- **Type consistency:** `Switch({value, onChanged, size, focusNode, autofocus, semanticLabel})`, `SwitchSize{sm, md}`, `_geometry()→({trackW,trackH,thumb,pad})` used identically across impl, tests, gallery, goldens. +- **Placeholder scan:** none — complete component source + full test code + exact commands. +- **Deviation honesty:** dropped `shadow-xs` + dark refinements are recorded in the class doc with the precedent; the dependency-free animation is a deliberate, stated choice (flutter_animate is for entrance/exit, not a toggle). No capability lowered — Switch is fully animated, accessible, and keyboard-operable; the dark-thumb contrast note is honest, not a silent gap. +```