critical priority low complexity frontend pending frontend specialist Tier 0

Acceptance Criteria

ModalCloseButton is a StatelessWidget in lib/widgets/modal_close_button.dart
Widget renders an Icons.close icon button with a visible 'Close' tooltip
Tap area is at least 44×44 logical pixels (enforced via SizedBox or minimumSize on the IconButton style)
Widget is wrapped in a Semantics widget with label: 'Close dialog'
Widget accepts an optional onPressed callback; if not provided, defaults to Navigator.pop(context)
Widget is consistently aligned to the top-right corner when used inside a modal — this is achieved by the widget itself using Align(alignment: Alignment.topRight) rather than requiring the parent to position it
The icon color meets WCAG 2.2 AA contrast ratio against the modal background color as defined in the design token system
Widget renders correctly in both light and dark design token themes
Widget test confirms the close icon is present, has correct semantics label, and the tap area is ≥44×44dp (using AccessibilityAuditRunner from task-003 once available, or manual rect check)
No hardcoded colors — all colors sourced from design token constants

Technical Requirements

frameworks
Flutter
flutter_test
performance requirements
Widget build must be a single pass with no layout thrashing — use const constructor where possible
ui components
ModalCloseButton
IconButton (Flutter Material)
Semantics
Tooltip

Execution Context

Execution Tier
Tier 0

Tier 0 - 440 tasks

Implementation Notes

Implement as a const-eligible StatelessWidget: class ModalCloseButton extends StatelessWidget { const ModalCloseButton({super.key, this.onPressed}); final VoidCallback? onPressed; }. Use IconButton with style: IconButton.styleFrom(minimumSize: const Size(44, 44)) to enforce touch target without needing an outer SizedBox. Wrap with Semantics(label: 'Close dialog', button: true, child: ...) — the button: true flag is important so screen readers announce it as a button role.

For the default dismiss action, use: onPressed ?? () => Navigator.of(context).pop(). The widget intentionally does NOT call SemanticsService.announce() on close — that is handled in task-006 to keep concerns separated. Use AppColors.iconPrimary (or equivalent design token) for the icon color.

Position responsibility: the widget wraps itself in Align(alignment: Alignment.topRight) so any parent (BottomSheet header, AlertDialog title row, etc.) simply includes ModalCloseButton() without additional positioning code.

Testing Requirements

Write widget tests using flutter_test. Test 1: Pump ModalCloseButton inside a MaterialApp scaffold — confirm Icons.close is rendered. Test 2: Confirm the rendered Rect of the tappable area is at least 44×44 logical pixels via tester.getRect(). Test 3: Confirm Semantics node with label 'Close dialog' exists using tester.getSemantics().

Test 4: Tap the button with no onPressed provided — confirm Navigator.pop() is triggered (wrap in a test route and verify the route is popped). Test 5: Provide a custom onPressed spy — confirm it is called on tap. Test 6: Render in dark theme — confirm no overflow or invisible icon.

Component
Modal Close Button
ui low
Epic Risks (3)
high impact high prob technical

Flutter's ModalBottomSheet and showDialog do not automatically confine VoiceOver or TalkBack focus to the modal's subtree on all platform versions. Background content may remain reachable by screen readers, confusing users and violating WCAG 2.2 criterion 1.3.1.

Mitigation & Contingency

Mitigation: Wrap modal content in an ExcludeSemantics or BlockSemantics widget for background content. Use a Semantics node with liveRegion on the modal container and manually request focus via FocusScope after the modal animation completes. Test on both iOS (VoiceOver) and Android (TalkBack) during widget development.

Contingency: If platform-level focus trapping is unreliable, implement a custom modal wrapper widget that uses a FocusTrap widget (available in Flutter's internal tooling) and an Overlay entry with semantics blocking on the dimmed background layer.

medium impact medium prob technical

On iOS, the system-level swipe-back gesture (UINavigationController) can bypass PopScope and GoRouter's gesture suppression, meaning users can still accidentally dismiss screens via swipe even after the component is implemented. This breaks the gesture-free contract for motor-impaired users.

Mitigation & Contingency

Mitigation: Set popGestureEnabled: false in GoRouter route configurations where swipe-back is suppressed. Test specifically against Flutter's CupertinoPageRoute, which respects this flag, and verify that GoRouter generates Cupertino routes on iOS rather than Material routes with gesture enabled.

Contingency: If go_router's popGestureEnabled flag does not propagate correctly, wrap affected routes in a WillPopScope replacement (PopScope with canPop: false) and file a bug with the go_router maintainers. Document the workaround in the navigation-route-config component for future maintainers.

medium impact medium prob scope

The feature description implies migrating all existing ModalBottomSheet and dialog call sites across the app to use the new accessible helpers, which is a cross-cutting change. Scope underestimation could mean the epic finishes the new components but leaves many call sites un-migrated, leaving the accessibility promise partially broken.

Mitigation & Contingency

Mitigation: Audit all existing modal call sites at the start of the epic (grep for showModalBottomSheet, showDialog, showCupertinoDialog) and add the count to the task list. Treat migration as explicit tasks, not an implied post-step.

Contingency: If migration scope grows beyond the epic's estimate, create a follow-up tech-debt epic scoped only to call-site migration, and gate the release on at minimum all flows used by the accessibility user-story acceptance criteria being migrated.