# Accessibility As Code

> Enforces accessibility as a correctness property authored into each widget — Semantics(button/label) or ExcludeSemantics on every node, a11y state read from MediaQuery not app state, never MediaQuery.withClampedTextScaling / textScaleFactor / FittedBox / TextOverflow.ellipsis to fit a label, non-color redundant channels (icon+label+shape+text) for every state, contrast against composited backgrounds (4.5:1 body / 3:1 large), 44px single-tap targets, OrdinalSortKey traversal, and honoring boldText / reduce-motion. Use when adding a GestureDetector/InkWell or any tap target, adding an Icon or Image, reaching for withClampedTextScaling/FittedBox/ellipsis/textScaleFactor to make text fit, encoding state via color, sizing type, ordering focus traversal, or reviewing any View for screen-reader/switch/low-vision support.

- Skill: `zakariaf/accessibility-as-code` (Agent Skill)
- Install (CLI): `npx skillmds@latest add zakariaf/accessibility-as-code`
- Raw SKILL.md: https://api.skillmd.com/api/skills/zakariaf/accessibility-as-code/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: zakariaf (https://skillmd.com/u/zakariaf)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/zakariaf/accessibility-as-code

---


# Accessibility is a coding standard, not a checklist

Accessibility is authored into the widget as you write it — the same tier as "the button actually works." There is no lint for any of this: `flutter_lints` and `very_good_analysis` ship zero a11y rules. Enforcement is the widget itself, review of your own diff, and widget tests. Applies to every interactive View, every `Icon`/`Image`, every state, and every color choice.

Testing mechanics (guideline matchers, golden lanes, RTL, honest conformance limits) live in `widget-golden-and-a11y-testing`; this skill is the authoring discipline.

## Non-negotiable rules

1. **Every interactive node gets `Semantics(button: true, label: ...)`** — a raw `GestureDetector` with no semantics silently locks out every screen-reader and switch user. Correct semantics make TalkBack, VoiceOver, Switch Access and Switch Control work for free; it is definition-of-done, never a backlog ticket.
2. **Every `Icon`/`Image` gets a `semanticLabel`, or is wrapped in `ExcludeSemantics`** because it is decorative. There is no third option — an unlabelled `Icon` is invisible to a screen reader.
3. **Read a11y state from `MediaQuery` at build time, never from app state.** `MediaQuery` is an `InheritedWidget` with correct-by-construction invalidation; pushing it through a provider trades a compiler-guaranteed rebuild for a frame of staleness in the one area where being wrong is total failure.
4. **Never `MediaQuery.withClampedTextScaling`, never `textScaleFactor`.** Both are clamping hacks that silently defeat the text-scale matrix while contrast and tap-target guidelines still pass green. Enforce with a source grep (see Anti-patterns).
5. **Never `FittedBox`, computed `fontSize`, or `TextOverflow.ellipsis` to make a label fit.** They turn "doesn't fit at 200%" from a loud test failure into truncated or shrunk text on a device. Build flexible heights and let text wrap.
6. **Never encode state through color alone.** Pair every stateful signal with at least one non-color channel — icon + text label + shape/pattern + position. Color-only state is invisible under `invertColors`, grayscale color-correction, and to every screen-reader user.
7. **Contrast is measured against the composited background in both themes** — body/icon text ≥ 4.5:1 (AA 1.4.3), large/hero text ≥ 3:1 (AA) — large text never has to clear a higher bar than body. Never test against the nominal token color when a fill, gradient, or translucency sits between text and surface.
8. **Every interactive target is ≥ 44×44 logical pixels and single-tap.** No long-press or precise-gesture-only affordances; hand-off and motor-impaired use demand a large forgiving target.
9. **Traversal order is authored with `sortKey`, never inherited from layout** — visual position optimized for the thumb actively pessimises linear screen-reader/switch scanning.
10. **Honor `boldText`; keep haptics through reduced motion.** Hardcoding `fontWeight` throws the setting away. Read `disableAnimations` from `MediaQuery` here, but resolve *how* motion collapses through `design-system-structure`'s reduced-motion token + `resolveMotion` helper (that skill owns the mechanism) — an animation that still conveys meaning with no reduced-motion path is a bug.

## Where a11y state comes from

Read platform accessibility state from `context`, app state from your notifier:

```dart
@override
Widget build(BuildContext context) {
  final boldText = MediaQuery.boldTextOf(context);
  final highContrast = MediaQuery.highContrastOf(context);
  final reduceMotion = MediaQuery.disableAnimationsOf(context);
  // textScaler is deliberately NOT read and NOT clamped. Text scales itself.
  ...
}
```

`MediaQuery.highContrastOf` is **iOS-only and always false on Android** (`AccessibilityFeatures.highContrast` is documented "Only supported on iOS"). Read the flag opportunistically; never gate anything on it, and provide an in-app contrast/theme control as the mechanism that works everywhere. `invertColors` is a system compositing filter — never reimplement it; just never encode meaning in color alone, or an inverted screen changes what the UI says.

## The tap target, wrong and right

```dart
// WRONG — invisible to a screen reader, a clamping hack, a hidden overflow bug
class ItemTile extends StatelessWidget {
  const ItemTile({super.key, required this.item, required this.onTap});
  final Item item;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return MediaQuery.withClampedTextScaling(   // (1) BANNED clamp
      maxScaleFactor: 1.3,
      child: GestureDetector(
        onTap: onTap,
        child: Column(
          children: [
            const Icon(Icons.star),             // (2) no semanticLabel
            Text(
              item.title,                        // (3) no Semantics button role
              style: const TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.normal,   // (4) ignores boldText
              ),
              overflow: TextOverflow.ellipsis,   // (5) HIDES the overflow bug
            ),
          ],
        ),
      ),
    );
  }
}
```

```dart
// RIGHT — semantics authored, text free to scale, target ≥ 44px, non-color state
class ItemTile extends StatelessWidget {
  const ItemTile({super.key, required this.item, required this.onToggle});
  final Item item;
  final void Function(ItemId id) onToggle; // resolve at tap from the stable id

  @override
  Widget build(BuildContext context) {
    final bold = MediaQuery.boldTextOf(context);
    final theme = Theme.of(context);
    final l10n = AppLocalizations.of(context); // non-null getter (i18n-rtl-l10n owns this contract)

    return Semantics(
      container: true,
      button: true,
      label: item.title,                       // display label, not a sentence
      // Non-color state: read out AND flip an icon (below), never color alone.
      value: item.isSelected ? l10n.selected : l10n.notSelected, // localized, not literal
      sortKey: OrdinalSortKey(item.priority.toDouble()), // priority, not layout
      child: GestureDetector(
        behavior: HitTestBehavior.opaque,       // whole cell is the target
        onTap: () => onToggle(item.id),         // stable id, no stale-closure capture
        child: ConstrainedBox(
          constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
          child: ExcludeSemantics(              // face already announced above
            child: Row(
              children: [
                Icon(item.isSelected ? Icons.check_circle : Icons.circle_outlined),
                Text(
                  item.title,                   // no ellipsis, no FittedBox
                  style: theme.textTheme.bodyLarge!.copyWith(
                    fontWeight: bold ? FontWeight.bold : FontWeight.normal,
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
```

The `label` is the **display label**, never a longer vocalization — nothing in the type system distinguishes the two `String`s, so a scanning user hears the whole sentence on every step unless you keep them separate. Resolve the action from the immutable id **at tap time**; capturing a mutable field into the closure fires the stale value on a fast re-tap.

## Text scale: the instinct is the bug

The reflex when a fixed layout overflows at 200% is to disable text scaling for stability. That reflex is the defect. Auto-shrinking — `FittedBox`, computed `fontSize`, `maxLines` + `ellipsis` — is the identical bug in disguise: the layout stays tidy and the user's setting stops working, silently, with every guideline still green.

- Honor `MediaQuery.textScalerOf(context)` by **not touching it**. Flutter text widgets already scale.
- `textScaleFactor` is deprecated in favour of `TextScaler` precisely to support Android's nonlinear scaling; its use is almost always a clamp.
- Build **intrinsic/flexible heights**; let text wrap to as many lines as it needs. Hero and display sizes may cap their own growth and **wrap to a second line rather than truncate**.
- Reflow horizontal rows (chip bars, toolbars) at accessibility text sizes instead of clipping.
- Use `FontFeature.tabularFigures()` (or `.monospacedDigit()`-style tabular numerals) wherever numbers change or align so they don't reflow or jitter.
- Let overflow **scream in tests**. A red/yellow overflow stripe in a widget test is the feedback loop; a truncated word on a device is a failure nobody reports.

## Never state through color alone

A real state — selected, active, pending, complete, error, disabled — is knowable without color. Encode it redundantly across independent channels:

- **Icon / glyph** whose *shape* differs per state (grayscale-legible).
- **Text label** — the word for the state, routed through localization.
- **Shape / pattern / outline** — a monotonic stripe, keyline, or notch.
- **Position** — sort the item that needs attention to the top under a header.

Expose the state to assistive tech through `Semantics` — a `value`/`hint`, or a `liveRegion` for a transient announcement — never through a luminance step alone:

```dart
Semantics(
  liveRegion: true, // announced when it changes, e.g. "Sync complete"
  label: statusLabel(status), // the word, not the color
  child: StatusBadge(status: status), // icon + label + shape, color is decoration
)
```

Pair visual feedback with `HapticFeedback.selectionClick()` so confirmation survives reduced motion and low vision. Guard any latched "active" visual with a timeout that force-clears it — a stuck state is both a visual lie and a stale semantic value.

## Contrast against the composited background

Verify contrast against what the pixel actually composites to, in **both** light and dark, not against the nominal token:

- Body text and meaningful icons/outlines ≥ **4.5:1** (AA 1.4.3); push to **7:1** (AAA 1.4.6) for body where you can reach it. Large/hero text ≥ **3:1** (AA) or **4.5:1** (AAA) — large text is never required to exceed body.
- Never render a critical number or label on a gradient, translucent glass, or chip fill; put it on an opaque surface.
- Any accent fill that carries text must be pre-paired with a foreground that clears 4.5:1 on *that* fill, re-verified in dark mode (dark values are hand-tuned, never auto-flipped).
- Avoid the red/green trap: a neutral value is neutral ink, "success" is green **+ ✓ glyph + the word**, danger-red appears only on destructive actions.
- Verify the whole UI in a grayscale pass and deuteranopia / protanopia / tritanopia simulation.

## Traversal order is a design decision

Layout optimized for thumb reach (highest-priority controls low and central) combined with Flutter's default row-major traversal makes the most important control the last thing a linear scanner reaches. Decouple traversal from position with one argument:

```dart
Semantics(
  sortKey: OrdinalSortKey(item.priority.toDouble()), // authored from priority
  ... )
```

Assert `sortKey` order equals priority order (not layout order) in a test. Know the limit: Flutter publishes no Switch Access support statement and no API simulates scanning or group selection, so this is a regression guard on *intent* — real-device passes with TalkBack, VoiceOver, and Switch Access are the only conformance evidence. Touch, switch, and screen reader are three different channels: design for all three or state plainly which one was dropped.

## Anti-patterns

- **`MediaQuery.withClampedTextScaling` / `textScaleFactor`** — the one-line "fix" a future contributor reaches for when an overflow test goes red; it defeats the entire text-scale matrix silently. Ban with `grep -rn "withClampedTextScaling\|textScaleFactor" lib/` in CI.
- **`FittedBox` / computed `fontSize` / `ellipsis` on a real label** — same bug wearing a disguise; the setting stops working while the layout stays tidy.
- **Unlabelled `Icon`/`Image`** — invisible to a screen reader; must be labelled or `ExcludeSemantics`.
- **Reading `boldText`/`highContrast`/`disableAnimations` from a provider** — one frame stale, in the area where wrong is total failure.
- **Gating anything on `highContrastOf`** — always false on Android; it silently disables your feature on the majority platform.
- **Color as the only channel for a state** — dies under `invertColors`, grayscale mode, and screen readers.
- **Capturing a mutable field into an `onTap` closure** — fires the stale value on a fast re-tap; resolve from the stable id at tap time.
- **A precise-gesture-only or long-press-only affordance** — excludes motor-impaired and hand-off use; provide a ≥44px single tap.
- **`containsSemantics(...)` in tests** — deprecated; use `isSemantics(...)`.

## Definition of done

For any widget with a tap target or state:

1. `Semantics` node with `button: true`, a display-only `label`, and an authored `sortKey`.
2. Every `Icon`/`Image` labelled or explicitly `ExcludeSemantics`.
3. No clamp, no `FittedBox`, no `ellipsis` on a real label; renders at `TextScaler.linear(2.0)` without overflow.
4. `boldText` honored; no hardcoded `fontWeight` on user-facing text.
5. Every state carries a non-color channel; transient state uses `liveRegion` or `value`; feedback includes a haptic.
6. Contrast verified against the composited background in both themes (≥4.5:1 body / ≥3:1 large) and in a grayscale + CVD pass.
7. Interactive targets ≥ 44×44 and single-tap.
8. A test asserting the semantics with `isSemantics(...)` and traversal against priority order.

## Related skills

- `widget-golden-and-a11y-testing` — the guideline matchers, golden lanes, RTL goldens, and honest a11y-conformance limits that verify this authoring.
- `i18n-rtl-l10n` — state labels and announcements routed through gen-l10n/ARB, and Directional geometry for correct-by-construction RTL.
- `design-system-structure` — token→theme layering, hand-authored `ColorScheme`, the no-raw-values gate that keeps contrast pairings honest, and the reduced-motion token + `resolveMotion` helper it owns (this skill reads the flag, that skill resolves the motion).
- `state-management-riverpod` — the split both skills co-own: app/domain state flows through Riverpod notifiers, while platform/a11y flags are read from `MediaQuery`/`BuildContext` and never routed through a provider.
- `widget-composition` — small const Views, dumb widgets, and dispose discipline that this semantics work sits on top of.
- `design-review-workflow` — the end-of-build screenshot sweep (light/dark × RTL × largest text × reduce-motion) where a11y floors are always blocking.

## References

- Flutter accessibility: https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility
- `Semantics` widget: https://api.flutter.dev/flutter/widgets/Semantics-class.html
- `MediaQueryData` accessibility flags: https://api.flutter.dev/flutter/widgets/MediaQueryData-class.html
- `TextScaler`: https://api.flutter.dev/flutter/painting/TextScaler-class.html
- `OrdinalSortKey`: https://api.flutter.dev/flutter/semantics/OrdinalSortKey-class.html
- WCAG 2.2 contrast (1.4.3 / 1.4.6): https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html

