mcpField
A
accessibility-as-code

accessibility-as-code

github.com/zakariafデザイン・UI63165 回インストール5 週間前に更新
リポジトリ

accessibility-as-code について

Flutter 開発者向けのアクセシビリティ実装スキルです。セマンティクス、文字の拡大、コントラスト、タップ領域、読み上げ順序を定め、ウィジェットテストでの検証も扱います。

主な機能

  1. 01操作要素にセマンティクスを付与
  2. 02画像にラベルを付けるか装飾扱いにする
  3. 03システムの文字拡大と太字設定を尊重
  4. 04色以外の手掛かりで状態を表現
  5. 05コントラストとタップ領域の要件を設定
  6. 06支援技術の読み上げ順を定義

活用シーン

  1. 01スクリーンリーダー対応の操作要素の構築
  2. 02文字を拡大しても切れない画面の設計
  3. 03色に頼らず状態を伝える表示の作成

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/zakariaf/flutter-skills --skill accessibility-as-code

インストール後、Claude Code で/accessibility-as-codeを入力して読み込まれたか確認します。

詳細

アクセシビリティはチェックリストではなく、コーディング標準です

アクセシビリティは、ウィジェットを書きながら組み込むものです。「ボタンが実際に動作すること」と同じレベルの要件です。これらを対象とするリントはありません。flutter_lints と very_good_analysis には、アクセシビリティのルールが一切含まれていません。徹底する手段は、ウィジェット自体、自分の差分のレビュー、ウィジェットテストです。すべてのインタラクティブな View、すべての Icon/Image、すべての状態、すべての色の選択に適用されます。

テストの仕組み(ガイドライン用マッチャー、ゴールデンテストのレーン、RTL、適合性に関する正直な限界)は widget-golden-and-a11y-testing に記載されています。このスキルでは、実装時の規律を扱います。

譲れないルール

  1. すべてのインタラクティブなノードに Semantics(button: true, label: ...) を設定します — セマンティクスのない生の GestureDetector は、スクリーンリーダーのユーザーやスイッチ操作のユーザーを気づかれないまま締め出します。適切なセマンティクスを設定すれば、TalkBack、VoiceOver、Switch Access、Switch Control がすぐに機能します。これは完了の定義に含まれるもので、バックログに回すチケットではありません。
  2. すべての Icon/Image に semanticLabel を設定するか、装飾目的の場合は ExcludeSemantics で囲みます。 第三の選択肢はありません。ラベルのない Icon は、スクリーンリーダーから見えません。
  3. アクセシビリティの状態はビルド時に MediaQuery から読み取り、アプリの状態からは読み取りません。 MediaQuery は正しい無効化を構築時に保証する InheritedWidget です。プロバイダー経由にすると、コンパイラーによって保証される再ビルドを、誤りが致命的な失敗につながる唯一の領域で、1 フレーム分古い状態に置き換えてしまいます。
  4. MediaQuery.withClampedTextScaling も textScaleFactor も使用しません。 どちらもクランプするハックであり、コントラストやタップターゲットのガイドラインは引き続きグリーンのまま、テキストスケールのテストマトリクスをひそかに無効化します。ソースコードの grep で検出してください(「アンチパターン」を参照)。
  5. ラベルを収めるために FittedBox、計算した fontSize、TextOverflow.ellipsis を使用しません。 これらを使うと、「200% で収まらない」という明白なテスト失敗が、デバイス上でテキストが切り詰められたり縮小されたりする状態に変わります。柔軟な高さを設けて、テキストを折り返してください。
  6. 状態を色だけで表現しません。 状態を示す信号には、色以外のチャンネルを少なくとも 1 つ組み合わせてください。たとえば、アイコン+テキストラベル+形状/パターン+位置です。色だけによる状態表現は、invertColors、グレースケールの色補正のもとでは見えず、スクリーンリーダーのユーザーにも伝わりません。
  7. どちらのテーマでも、合成後の背景に対してコントラストを測定します — 本文/アイコンのテキストは 4.5:1 以上(AA 1.4.3)、大きな文字/ヒーローテキストは 3:1 以上(AA)です。大きな文字に本文より厳しい基準を課す必要はありません。テキストとサーフェスの間に塗りつぶし、グラデーション、または半透明のレイヤーがある場合、公称トークンの色に対してテストしないでください。
  8. すべてのインタラクティブなターゲットは 44×44 論理ピクセル以上で、1 回のタップで操作できる必要があります。 長押しや、精密なジェスチャーだけを頼りにする操作方法は設けません。操作の引き継ぎや運動機能に障害のあるユーザーの利用には、大きく操作しやすいターゲットが必要です。
  9. 読み上げ順は sortKey で明示し、レイアウトから継承させません — 親指での操作に合わせた視覚的位置は、スクリーンリーダーやスイッチで順に読み上げる操作を積極的に悪化させます。
  10. boldText を尊重し、モーションを減らす設定でも触覚フィードバックを維持します。 fontWeight をハードコードすると、設定が無視されます。ここでは MediaQuery から disableAnimations を読み取りますが、モーションをどう変化させるかは design-system-structure の reduced-motion トークンと resolveMotion ヘルパーを使って解決してください(仕組みの担当はそのスキルです)。モーションを減らす場合の対応経路がなく、意味を伝えるためにアニメーションを使い続けるのはバグです。

アクセシビリティの状態の取得元

プラットフォームのアクセシビリティ状態は context から、アプリの状態は 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 は iOS 専用で、Android では常に false です(AccessibilityFeatures.highContrast のドキュメントには「Only supported on iOS」と記載されています)。このフラグは利用できる場合に読み取るだけにしてください。何かの条件判定には使用せず、どこでも使える仕組みとして、アプリ内のコントラスト/テーマ設定を用意してください。invertColors はシステムの合成フィルターです。再実装してはいけません。意味を色だけで表現しないようにしてください。そうすれば、画面の色が反転しても UI が伝える内容は変わりません。

タップ対象:誤りと正解

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

label は表示ラベルであり、長い読み上げ文ではありません。型システム上、2 つの String は区別されないため、別々に管理しないと、ユーザーが項目を移動するたびに文全体が読み上げられます。不変の ID を使ってタップ時にアクションを解決してください。変更可能なフィールドをクロージャにキャプチャすると、短時間に再タップされた際に古い値で処理されます。

テキストスケール:直感こそがバグです

固定レイアウトが 200% であふれたとき、安定性のためにテキストスケーリングを無効にするのがよくある反応です。その反応こそが不具合です。自動縮小、つまり FittedBox、計算した fontSize、maxLines と ellipsis の組み合わせも、形を変えただけの同じバグです。レイアウトは整ったままで、ユーザーの設定はひそかに機能しなくなり、すべてのガイドラインがグリーンのままです。

  • MediaQuery.textScalerOf(context) を尊重し、変更しないでください。Flutter のテキストウィジェットはすでにスケーリングに対応しています。
  • textScaleFactor が TextScaler に置き換えられたのは、Android の非線形スケーリングに対応するためです。これを使うと、ほとんどの場合、スケーリングを制限することになります。
  • 固有の高さや柔軟な高さを使い、テキストが必要な行数だけ折り返すようにしてください。ヒーローサイズやディスプレイサイズのテキストは、それ自体の拡大に上限を設けてもかまいませんが、切り詰めるのではなく2 行目に折り返してください。
  • アクセシビリティ用のテキストサイズでは、チップバーやツールバーなどの横並びの行をクリップせず、折り返してください。
  • 数値が変化または整列する箇所では、FontFeature.tabularFigures()(または .monospacedDigit() のような等幅数字)を使い、再レイアウトやちらつきを防いでください。
  • あふれはテストで明らかになるようにしてください。ウィジェットテストで表示される赤と黄色のオーバーフロー帯がフィードバックになります。端末で単語が切り詰められるのは、誰にも報告されない失敗です。

色だけで状態を表さない

選択中、アクティブ、保留中、完了、エラー、無効といった実際の状態は、色がなくても把握できるようにします。互いに独立した複数の手段を組み合わせて表現してください。

  • アイコン/グリフ:状態ごとに形状を変えます(グレースケールでも判別できます)。
  • テキストラベル:状態を表す単語をローカライズして表示します。
  • 形状/パターン/輪郭:一定方向のストライプ、キーポイント、切り欠きなどを使います。
  • 位置:注意が必要な項目を、ヘッダーの下で一番上に並べます。

Semantics を使って、value/hint、または一時的な通知には liveRegion を通じて支援技術に状態を伝えてください。輝度の変化だけで伝えてはいけません。

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
)

視覚的なフィードバックには HapticFeedback.selectionClick() を組み合わせ、動きを抑える設定や弱視のユーザーにも確認が伝わるようにしてください。「アクティブ」の視覚表示を保持する場合は、タイムアウトで必ず解除するようにしてください。状態が解除されずに残ると、視覚的にも誤った表示になり、セマンティックな値も古いままになります。

合成後の背景に対するコントラスト

公称のトークン値ではなく、ピクセルが実際に合成された色に対するコントラストを、ライトモードとダークモードの両方で確認してください。

  • 本文テキストと意味のあるアイコン/輪郭は ≥ 4.5:1(AA 1.4.3)にしてください。可能なら本文は 7:1(AAA 1.4.6)を目指してください。大きなテキストやヒーローテキストは ≥ 3:1(AA)または 4.5:1(AAA)にしてください。大きなテキストに本文以上のコントラストが求められることはありません。
  • 重要な数値やラベルを、グラデーション、半透明のガラス表現、チップの塗りつぶしの上に表示しないでください。不透明な面に配置してください。
  • テキストを載せるアクセントカラーの塗りつぶしには、必ず 4.5:1 を満たす前景色をあらかじめ組み合わせ、ダークモードでも再確認してください(ダークモードの値は自動反転ではなく、手動で調整します)。
  • 赤と緑の罠を避けてください。中立的な値には中立色を使います。「成功」は緑と ✓ グリフと単語で表し、危険を示す赤は破壊的なアクションにだけ使います。
  • UI 全体をグレースケールにし、1 型、2 型、3 型の色覚特性のシミュレーションでも確認してください。

トラバーサル順序は設計上の判断です

親指が届きやすい位置に最適化したレイアウト(最優先のコントロールを下部中央に配置)と、Flutter のデフォルトの行優先トラバーサルを組み合わせると、最も重要なコントロールが線形スキャナーで最後に到達する要素になります。引数を 1 つ指定して、位置とトラバーサル順序を切り離します。

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

テストでは、sortKey の順序がレイアウト順ではなく優先順位と一致することを検証します。制限も把握しておきましょう。Flutter は Switch Access のサポートについて何も表明しておらず、スキャンやグループ選択をシミュレートする API もありません。そのため、これはあくまで意図に対するリグレッションガードです。適合性の根拠となるのは、TalkBack、VoiceOver、Switch Access を使った実機での検証だけです。タッチ、スイッチ、スクリーンリーダーはそれぞれ異なる 3 つの操作手段です。3 つすべてに対応する設計にするか、どれを対象外にしたのかを明確に示してください。

アンチパターン

  • MediaQuery.withClampedTextScaling / textScaleFactor — オーバーフローテストが失敗したとき、将来のコントリビューターが手軽に使ってしまう一行の「修正」です。テキストスケールのテストマトリクス全体をひそかに無効にします。CI で grep -rn "withClampedTextScaling\|textScaleFactor" lib/ を使って禁止してください。
  • 実際のラベルに対する FittedBox / 計算した fontSize / ellipsis — 見た目を変えただけの同じバグです。レイアウトは整ったまま、設定が機能しなくなります。
  • ラベルのない Icon/Image — スクリーンリーダーから認識できません。ラベルを付けるか、ExcludeSemantics を指定する必要があります。
  • プロバイダーから boldText/highContrast/disableAnimations を読み取る — 誤りが致命的な失敗につながる領域で、値が 1 フレーム遅れます。
  • highContrastOf に基づいて何かを有効・無効にする — Android では常に false です。利用者の大半を占めるプラットフォームで、機能がひそかに無効になります。
  • 状態を色だけで表す — invertColors、グレースケールモード、スクリーンリーダーでは機能しません。
  • 変更可能なフィールドを onTap クロージャーにキャプチャする — 短時間で再タップすると古い値が使われます。タップ時に安定した ID から値を解決してください。
  • 正確なジェスチャーや長押しだけで操作するアフォーダンス — 運動機能に制約のある方や、端末の受け渡し時の利用を排除してしまいます。44px 以上のシングルタップ操作を用意してください。
  • テストでの containsSemantics(...) — 非推奨です。isSemantics(...) を使ってください。

完了の定義

タップ対象または状態を持つウィジェットでは、次のすべてを満たしてください。

  1. button: true、表示専用の label、作成者が指定した sortKey を持つ Semantics ノードがある。
  2. すべての Icon/Image にラベルを付けるか、明示的に ExcludeSemantics を指定する。
  3. クランプ、FittedBox、実際のラベルへの ellipsis を使わず、TextScaler.linear(2.0) でもオーバーフローしない。
  4. boldText に対応し、ユーザー向けテキストに fontWeight をハードコードしない。
  5. すべての状態に色以外の伝達手段があり、一時的な状態には liveRegion または value を使い、フィードバックに触覚効果を含める。
  6. 両方のテーマで、合成後の背景色に対するコントラストを検証する(本文は 4.5:1 以上、大きな文字は 3:1 以上)。グレースケールと CVD の検証にも合格する。
  7. 操作対象は 44×44 以上で、シングルタップで操作できる。
  8. isSemantics(...) を使ってセマンティクスを検証し、優先順位に対するトラバーサル順序も検証するテストがある。

関連スキル

  • widget-golden-and-a11y-testing — ガイドラインに対応するマッチャー、ゴールデンテストのレーン、RTL ゴールデン、そして作成した内容を検証するための、誠実なアクセシビリティ適合性の限界。
  • i18n-rtl-l10n — gen-l10n/ARB を通じて状態ラベルとアナウンスを扱い、方向性を考慮したジオメトリで、最初から正しく RTL に対応します。
  • design-system-structure — トークンからテーマへのレイヤリング、手動で作成する ColorScheme、コントラストの組み合わせを適切に保つ生値禁止のゲート、そしてこのスキルがフラグを読み取り、同スキルがモーションを解決する、軽減モーションのトークンと resolveMotion ヘルパー。
  • state-management-riverpod — 両スキルが共同で担う領域の分担です。アプリやドメインの状態は Riverpod の Notifier を通じて流し、プラットフォームやアクセシビリティのフラグは MediaQuery/BuildContext から読み取り、プロバイダー経由にはしません。
  • widget-composition — このセマンティクス対応の基盤となる、小さな const View、単純なウィジェット、適切な dispose の管理。
  • design-review-workflow — ビルド終盤のスクリーンショット確認(ライト/ダーク × RTL × 最大の文字サイズ × モーション軽減)。ここではアクセシビリティの最低基準を常に必須条件とします。

References

コメント