agentsclimarketplace

Accessibility as code

Skill zakariaf/Flutter-Skills/skills/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.From its SKILL.md

Install
npx -y skills add zakariaf/Flutter-Skills --skill accessibility-as-code

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

15.8 KB, ~3.5k tokens by cl100k_base, as published. Nobody here has run it

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:

@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

// 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
            ),
          ],
        ),
      ),
    );
  }
}
// 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 Strings, 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:

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:

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

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Gives 0 of the 12 instructions most accessibility skills give in ~3.5k tokens

Counted across 360 of the 378 authors here whose files we hold, read 2026-09-06

  • Respect prefers-reduced-motionin 49 of 360, across 41 files
  • Add alt text to every imagein 47 of 360, across 40 files
  • Make every interactive element keyboard operablein 46 of 360, across 37 files
  • Keep focus indicators visiblein 42 of 360, across 37 files
  • Associate every form input with a labelin 42 of 360, across 35 files
  • Ensure 4.5:1 contrast for normal textin 38 of 360, across 32 files
  • Trap focus in modals and restore it on closein 37 of 360, across 25 files
  • Prefer native HTML elements over ARIAin 37 of 360, across 33 files
  • Add aria-label to icon-only buttonsin 24 of 360, across 13 files
  • Announce dynamic content updates with aria-livein 24 of 360, across 16 files
  • Use ARIA only when native HTML is insufficientin 23 of 360, across 15 files
  • Meet WCAG AA contrast ratiosin 23 of 360

Said here and by no other author read

  • Label every icon and image or exclude it from semantics
  • Read accessibility state from MediaQuery at build time
  • Build flexible heights and let text wrap
  • Pair every state signal with a non-color channel
  • Make every tap target 44px and single-tap
  • Author traversal order with sortKey from priority

Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.