diff --git a/apps/gallery/lib/components/ui/switch.dart b/apps/gallery/lib/components/ui/switch.dart new file mode 100644 index 0000000..a207514 --- /dev/null +++ b/apps/gallery/lib/components/ui/switch.dart @@ -0,0 +1,204 @@ +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 CurvedAnimation _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() { + // Dispose the CurvedAnimation first — it removes its status listener from the + // controller — then the controller it wraps. + _t.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 thumbSize, double pad}) _geometry() { + return switch (widget.size) { + SwitchSize.sm => (trackW: 6, trackH: 3.5, thumbSize: 3, pad: 0.5), + SwitchSize.md => (trackW: 8, trackH: 4.5, thumbSize: 4, pad: 0.5), + }; + } + + @override + Widget build(BuildContext context) { + final c = context.fw.colors; + 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, + child: thumb, + builder: (context, child) { + final t = _t.value; + final trackColor = Color.lerp(c.input, c.primary, t)!; + final thumbAlign = + AlignmentDirectional.lerp( + AlignmentDirectional.centerStart, + AlignmentDirectional.centerEnd, + t, + )!; + + 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)); + } + 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/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/goldens/switch_grid_dark.png b/apps/gallery/test/goldens/switch_grid_dark.png new file mode 100644 index 0000000..bb70809 Binary files /dev/null and b/apps/gallery/test/goldens/switch_grid_dark.png differ diff --git a/apps/gallery/test/goldens/switch_grid_light.png b/apps/gallery/test/goldens/switch_grid_light.png new file mode 100644 index 0000000..8823354 Binary files /dev/null and b/apps/gallery/test/goldens/switch_grid_light.png differ 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 0000000..339f1dc Binary files /dev/null and b/apps/gallery/test/goldens/switch_grid_rtl.png differ diff --git a/apps/gallery/test/switch_behavior_test.dart b/apps/gallery/test/switch_behavior_test.dart new file mode 100644 index 0000000..3a1a887 --- /dev/null +++ b/apps/gallery/test/switch_behavior_test.dart @@ -0,0 +1,227 @@ +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('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(); + 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); + }); +} diff --git a/apps/gallery/test/switch_golden_test.dart b/apps/gallery/test/switch_golden_test.dart new file mode 100644 index 0000000..7bd376f --- /dev/null +++ b/apps/gallery/test/switch_golden_test.dart @@ -0,0 +1,87 @@ +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'), + ); + }); +} 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. +``` 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. ---