ethos 0.7.0 copy "ethos: ^0.7.0" to clipboard
ethos: ^0.7.0 copied to clipboard

Measure accessibility coverage in Flutter apps using WCAG 2.2 specifications with Spec-Driven Development.

Ethos #

Measure accessibility coverage in Flutter apps using WCAG 2.2 specifications with Spec-Driven Development.

Pub License: Apache-2.0

What is Ethos? #

Ethos measures what percentage of your Flutter widgets comply with WCAG 2.2 accessibility standards.

Unlike tools that detect individual issues, Ethos calculates coverage metrics for each rule, giving you a clear picture of your app's overall accessibility maturity.

πŸ“Š Overall Coverage: 44.6%
Compliance Level: NONE

πŸ“‹ Coverage by Rule:
  βœ… Semantic Labels:  96% (26/27)
  ℹ️  Color Contrast:   NO DATA  β“˜ 82 indeterminate
  ℹ️  Touch Targets:    NO DATA  β“˜ 905 indeterminate
  ⚠️  Keyboard Nav:    66% (8/12)  β€” CRITICAL
  ⚠️  Focus Order:     0%  (0/1)   β€” CRITICAL
  ⚠️  Non-text Content: 15% (7/45) β€” CRITICAL
  ℹ️  Resize Text:      NO DATA 

Features #

  • βœ… WCAG 2.2 alignment β€” coverage metrics, not one-off issue lists.
  • βœ… Honest AST analysis with package:analyzer β€” no regex, no guessing.
  • βœ… Indeterminate accounting β€” values from themes or runtime variables are reported separately and never inflate pass/fail ratios.
  • βœ… Built-in spec, zero setup β€” the WCAG 2.2 rules ship inside the package. You don't copy any YAML file.
  • βœ… Optional ethos.yaml β€” teach Ethos about your own design-system widgets and colors in five minutes.
  • βœ… Theme-aware contrast β€” resolves theme.textTheme.X automatically from your ThemeData, and accepts explicit color_aliases for custom style variables.
  • βœ… Deep analysis mode (--deep) β€” uses AnalysisContextCollection to resolve cross-file references, class hierarchies, and type information. Emits Stream<AnalysisProgress> events for live progress. Falls back to standard mode automatically if the project is not ready.
  • βœ… Pluggable detector registry β€” add or replace rules without touching the core engine.
  • βœ… Watch mode (ethos watch) β€” re-analyzes only the changed file on every save, prints the full report with β–²/β–Ό deltas, and highlights new findings.

Installation #

As a CLI #

dart pub global activate ethos
ethos -p ./my_flutter_app

As a library #

dependencies:
  ethos: ^0.6.0
import 'package:ethos/ethos.dart';

void main() async {
  final analyzer = await CoverageAnalyzer.forProject('./my_flutter_app');
  final report  = await analyzer.analyze();
  print('Coverage:   ${report.overallCoverage}%');
  print('Compliance: ${report.complianceLevel}');
}

You do not copy any spec file. The built-in WCAG 2.2 spec lives inside the package.


Quick start (local development) #

git clone https://github.com/gearscrafter/ethos.git
cd ethos
dart pub get

# Run against the bundled fixtures
dart run example/main.dart

# Run against your own Flutter project
dart run bin/analyze.dart -p ./my_flutter_app

# Install locally as a global command
dart pub global activate --source path .
ethos -p ./my_flutter_app

Standard vs Deep analysis #

Ethos has two analysis modes:

Standard Deep (--deep)
Speed Fast (seconds) Slower (10–60s)
Cross-file resolution βœ— βœ…
Class hierarchy traversal βœ— βœ…
Variable type resolution βœ— βœ…
Progress stream βœ— βœ…
Requires flutter pub get βœ— βœ… (auto-detects)

Standard mode is ideal for quick checks and CI gates. Deep mode is for comprehensive audits β€” it finds widgets that standard mode misses because they are defined in a different file from where they are used.

# Standard
ethos -p ./my_app

# Deep β€” with live progress
ethos -p ./my_app --deep -v

Deep mode falls back to standard automatically if the project context cannot be built (e.g. flutter pub get has not been run).

Deep mode as a library #

final deepAnalyzer = await DeepAnalyzer.forProject('./my_app');

await for (final event in deepAnalyzer.analyze()) {
  switch (event) {
    case AnalysisLoadingContext(:final totalFiles):
      print('Loading $totalFiles files...');
    case AnalysisAnalyzingFile(:final current, :final total):
      print('[$current/$total]');
    case AnalysisWarning(:final message):
      print('⚠️  $message');
    case AnalysisComplete():
      final report = (event as AnalysisComplete).report;
      print(report.toJsonString());
    default:
      break;
  }
}

Getting started with ethos init #

If your project uses a custom design system, the first thing to do after installing Ethos is run ethos init. It scans your project for custom widgets and unresolvable color expressions, then generates a starter ethos.yaml with everything pre-filled β€” you just review and fill in the values it can't infer.

ethos init -p ./my_app

Example output:

πŸ” Scanning ./my_app for custom widgets and color tokens...
   Scanned 189 Dart files

   Found 12 custom widget(s), 5 color expression(s)

βœ… Generated: ./my_app/ethos.yaml

πŸ“¦ Custom widgets (fill in role and label_arg):
   CircleIconBtn                  47 uses
   AppBtn                         23 uses
   WonderIllustration             18 uses
   AppHeader                       9 uses

🎨 Color expressions (add hex values to enable contrast checks):
   $styles.text.body              31 uses
   $styles.colors.offWhite        14 uses

Next steps:
  1. Open ./my_app/ethos.yaml
  2. Set role: for each widget_alias
  3. Uncomment label_arg, size_guaranteed, keyboard_ready as needed
  4. Fill in hex values under color_aliases
  5. Run: ethos -p ./my_app -v

The generated ethos.yaml looks like this β€” nothing is invented, only discovered. Values that require human knowledge are left as comments:

# ethos.yaml β€” generated by `ethos init`

widget_aliases:

  # CircleIconBtn β€” used 47 times
  CircleIconBtn:
    role: button           # button | text | input β€” REQUIRED
    # label_arg: ???       # e.g. semanticLabel, a11yLabel, label
    # size_guaranteed: true
    # keyboard_ready: true

  # AppBtn β€” used 23 times
  AppBtn:
    role: button
    # label_arg: ???

color_aliases:
  # used 31 times
  # "$styles.text.body":
  #   foreground: "#REPLACE_ME"
  #   background: "#REPLACE_ME"

Once you fill in the values and re-run ethos -p ./my_app, the widgets that were previously invisible (indeterminate) will start contributing to your coverage score.


Live feedback with ethos watch #

ethos watch performs an initial full scan and then re-analyzes only the file you just saved. Every change prints the full report with coverage deltas so you can see the impact of each edit immediately.

ethos watch -p ./my_app

# With deep analysis on each change:
ethos watch -p ./my_app --deep

Example output after saving a file:

──────────────────────────────────────────────────
πŸ”„  buttons.dart changed (14:23:05)
──────────────────────────────────────────────────

βœ… Initial scan complete (189 files)

πŸ“Š Overall:  71.4%  β–² +2.1%  Β· Compliance: A

βœ… Semantic Labels           85.0% (17/20)   β–² +5.0%
ℹ️  Color Contrast           100.0% (8/8)
βœ… Touch Targets             100.0% (8/8)
⚠️  Keyboard Nav              66.7% (8/12)   β–Ό -3.3%
βœ… Focus Order                95.0% (19/20)
⚠️  Non-text Content          15.6% (7/45)   CRITICAL
ℹ️  Resize Text               NO DATA

πŸ”Ž Findings in buttons.dart:
  βœ— line 42 β€” GestureDetector: Tap gesture has no keyboard alternative

Watching for changes... (Ctrl+C to stop)

Watch mode observes lib/, test/, and example/ with a 300 ms debounce. Generated files (.g.dart, .freezed.dart) are ignored automatically.

Watch mode as a library #

final engine = await WatchEngine.forProject('./my_app');

// Initial full scan
final baseline = await engine.initialScan(
  onProgress: (current, total, path) {
    print('[$current/$total] $path');
  },
);

// Re-analyze on file change
final (newReport, diff) = await engine.reanalyzeFile(changedPath);

print('Overall delta: ${diff.overallDelta > 0 ? "β–²" : "β–Ό"} ${diff.overallDelta.abs().toStringAsFixed(1)}%');
print('New critical rules: ${diff.newCritical}');
print('Resolved rules: ${diff.resolvedCritical}');

Configuration (optional): ethos.yaml #

Ethos works out of the box β€” the built-in spec already covers Flutter's standard widgets (GestureDetector, InkWell, IconButton, TextField, etc.).

Most real apps wrap controls in their own design-system components and define colors in a custom style object. Drop an ethos.yaml next to your pubspec.yaml to teach Ethos about them:

# ethos.yaml β€” OPTIONAL. Ethos auto-detects it; no flag needed.

widget_aliases:
  # Key = your widget's class name exactly as written in code.
  CircleIconBtn:
    role: button             # button | text | input
    label_arg: semanticLabel # which arg carries the accessible label
    size_guaranteed: true    # already wraps a >= 48Γ—48 target internally?
    keyboard_ready: true     # keyboard-operable out of the box?

  AppButton:
    role: button
    label_arg: a11yLabel

color_aliases:
  # Teach Ethos about your design-system color expressions so the contrast
  # rule can compute real WCAG ratios instead of reporting "indeterminate".
  "$styles.text.body":
    foreground: "#212121"   # required β€” the text color
    background: "#FFFFFF"   # optional β€” the default background color

  "$styles.colors.primary":
    foreground: "#1565C0"

# Optional: tighten a threshold without rewriting the spec.
# rule_overrides:
#   wcag_1_4_3_contrast_minimum:
#     critical_threshold: 95

widget_aliases

Field Detector Effect
role: button Semantic Labels, Keyboard, Touch Target Widget counts as an interactive control.
label_arg Semantic Labels Look for the semantic label in this argument.
size_guaranteed Touch Target Size Auto-PASS β€” already β‰₯ 48Γ—48 internally.
keyboard_ready Keyboard Accessibility Auto-PASS β€” keyboard-operable out of the box.

color_aliases

Maps a design-system style expression to concrete hex colors. Both #RRGGBB and #AARRGGBB formats are accepted. When background is omitted, the element remains indeterminate.

No ethos.yaml? Ethos still runs on the built-in spec. For a vanilla Flutter project that's already useful; for a project with a design system, the aliases make all the difference β€” and deep mode can discover many of them automatically.


Supported rules #

Seven built-in rules, all backed by RecursiveAstVisitor on real Dart AST. Deep mode runs enhanced versions of rules 1 and 2.

1. Semantic Labels β€” wcag_1_3_1_semantics_label (WCAG 1.3.1 Β· Level A) #

Custom interactive widgets must have an accessible label.

  • In scope: GestureDetector, InkWell, InkResponse with tap-like gestures, plus any role: button alias from ethos.yaml.
  • Pass: wrapped in Semantics(label: '<non-empty literal>') as ancestor or descendant; or the alias label_arg is a non-empty literal.
  • Indeterminate: label is a variable, interpolation, or runtime call.
  • Excluded automatically: excludeFromSemantics: true, drag/pan-only gestures, onTap: () {} (block-parent), tap-to-dismiss patterns.
  • Deep mode: also follows widget definitions across files and detects cross-method Semantics wrappers.
// βœ… PASS β€” Semantics as ancestor
Semantics(
  label: 'Open profile',
  child: GestureDetector(onTap: () {}, child: Icon(Icons.person)),
)

// βœ… PASS β€” Semantics as descendant also works
GestureDetector(
  onTap: () => navigate(),
  child: Semantics(label: 'Go to settings', child: Icon(Icons.settings)),
)

// ❌ FAIL
GestureDetector(onTap: () => navigate(), child: Icon(Icons.settings))

2. Minimum Color Contrast β€” wcag_1_4_3_contrast_minimum (WCAG 1.4.3 Β· Level AA) #

Text must have at least 4.5:1 contrast (3:1 for large text β‰₯ 18 pt). Resolution is attempted in layers:

  1. Inline literals β€” TextStyle(color: Color(0xFF...), backgroundColor: ...).
  2. ThemeData extraction β€” resolves theme.textTheme.bodyLarge etc.
  3. color_aliases β€” resolves design-system expressions from ethos.yaml.
  4. Deep mode only β€” follows variable references across files.
// βœ… PASS β€” ratio 21:1
Text('Hello', style: TextStyle(color: Colors.black, backgroundColor: Colors.white))

// ❌ FAIL β€” ratio ~1.6:1
Text('Hello', style: TextStyle(color: Color(0xFFCCCCCC), backgroundColor: Colors.white))

// β“˜ INDETERMINATE in standard mode; resolved in deep mode
Text('Hello', style: TextStyle(color: bodyColor))  // bodyColor defined elsewhere

3. Touch Target Size β€” wcag_2_5_5_target_size_enhanced (WCAG 2.5.5 Β· Level AAA) #

Interactive elements must be at least 48Γ—48 logical pixels.

  • Auto-pass: IconButton, FloatingActionButton; aliases with size_guaranteed: true.
  • Verifiable: custom widget in a SizedBox/Container with literal dimensions.
  • Indeterminate: size from a variable or intrinsic content.

4. Keyboard Accessibility β€” wcag_2_1_1_keyboard (WCAG 2.1.1 Β· Level A) #

All interactive functionality must be reachable by keyboard.

  • Pass: Material controls; GestureDetector under Focus/FocusScope/ Shortcuts/KeyboardListener; aliases with keyboard_ready: true.
  • Fail: GestureDetector.onTap with no keyboard path.
  • Excluded: excludeFromSemantics: true (visual-only wrappers).

5. Focus Order β€” wcag_2_4_3_focus_order (WCAG 2.4.3 Β· Level A) #

Multi-input layouts must declare explicit focus management.

  • In scope: Form widgets, or layouts with 2+ focusable inputs.
  • Pass: declares FocusNode, FocusScope, FocusTraversalGroup, or autofocus: true.

6. Non-text Content β€” wcag_1_1_1_non_text_content (WCAG 1.1.1 Β· Level A) #

All images and icons that convey information must have a text alternative. Purely decorative content must be explicitly excluded from the semantic tree.

  • In scope: Image, Image.asset, Image.network, Image.file, SvgPicture.asset, SvgPicture.network, Icon.
  • Pass: wrapped in Semantics(label: '...'), or excludeFromSemantics: true (decorative); for Icon, a non-empty literal semanticLabel: argument.
  • Indeterminate: label is a runtime variable.
  • Fail: no label and no explicit decoration marker.
// βœ… PASS β€” informative image with label
Semantics(
  label: 'Photo of the Colosseum',
  child: Image.network(url),
)

// βœ… PASS β€” decorative image explicitly excluded
Image.asset('assets/bg.png', excludeFromSemantics: true)

// βœ… PASS β€” icon with semantic label
Icon(Icons.search, semanticLabel: 'Search artifacts')

// ❌ FAIL β€” image without any accessibility annotation
Image.network(artifactUrl)

// ❌ FAIL β€” icon without semanticLabel
Icon(Icons.close)

7. Resize Text β€” wcag_1_4_4_resize_text (WCAG 1.4.4 Β· Level AA) #

Text must be resizable up to 200% without loss of content. Hardcoding textScaleFactor or textScaler to a fixed value ignores system font-size preferences set by users with visual impairments.

  • In scope: only Text and MediaQuery widgets that explicitly set textScaleFactor or textScaler. Widgets without these args are correct by default and are not counted.
  • Pass: textScaleFactor: null (inherits system), or variable-based scaling that cannot be verified statically.
  • Fail: literal textScaleFactor, TextScaler.noScaling, or TextScaler.linear(<literal>).
// βœ… PASS β€” inherits system preference (the right default)
Text('Hello')

// βœ… PASS β€” explicitly null (same as default)
Text('Hello', textScaleFactor: null)

// ❌ FAIL β€” locks font size, ignores accessibility settings
Text('Hello', textScaleFactor: 1.0)

// ❌ FAIL β€” forces no scaling
Text('Hello', textScaler: TextScaler.noScaling)

NO DATA is a good sign for Resize Text. It means your project does not override text scaling anywhere β€” which is exactly what WCAG requires.


CLI reference #

ethos -p <project-path> [options]
ethos init -p <project-path>        (generate starter ethos.yaml)
ethos watch -p <project-path>       (watch for changes and re-analyze)

Options (analyze):
  -p, --project-path   Path to the Flutter project to analyze (required)
  -c, --config         Path to a custom ethos.yaml (default: auto-detect)
  -r, --report-type    Output format: human | json | markdown | coverage
                       (default: human)
  -o, --output         Write report to this file instead of stdout
  -d, --deep           Deep analysis: resolves types and cross-file references.
                       Slower but more precise. Requires `flutter pub get`.
                       Falls back to standard mode if project is not ready.
  -v, --verbose        Show progress details (written to stderr)
  -h, --help           Show this help

Options (watch):
  -p, --project-path   Path to the Flutter project to watch (required)
  -d, --deep           Use deep analysis on each change
  -h, --help           Show this help

Examples:
  ethos -p ./my_app
  ethos -p ./my_app --deep
  ethos -p ./my_app --deep -v
  ethos -p ./my_app -c path/to/ethos.yaml
  ethos -p ./my_app -r json -o report.json
  ethos -p ./my_app -r markdown -o report.md
  ethos init -p ./my_app
  ethos init -p ./my_app -o path/to/ethos.yaml
  ethos watch -p ./my_app
  ethos watch -p ./my_app --deep

Verbose logs go to stderr so ethos -p . -r json | jq works cleanly. Exit code 1 when any rule is below its critical threshold β€” useful as a CI gate.


Compliance levels #

Level Minimum coverage Description
AAA β‰₯ 95% Enhanced accessibility
AA β‰₯ 85% Strong accessibility (typical target)
A β‰₯ 70% Basic accessibility
NONE < 70% Does not meet minimum standards

Library API reference #

// Standard entry point
final analyzer = await CoverageAnalyzer.forProject('./my_app');
final report = await analyzer.analyze();

// Deep entry point
final deepAnalyzer = await DeepAnalyzer.forProject('./my_app');
await for (final event in deepAnalyzer.analyze()) {
  if (event is AnalysisComplete) {
    final report = (event as AnalysisComplete).report;
    print(report.toJsonString());
  }
}

// Output
print(report.overallCoverage);   // double 0–100
print(report.complianceLevel);   // 'A' | 'AA' | 'AAA' | 'NONE'
print(report.toJsonString());    // JSON for CI pipelines

Architecture #

ethos/
β”œβ”€β”€ bin/
β”‚   └── analyze.dart               # CLI (analyze + init + watch subcommands)
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ ethos.dart                 # Public barrel export
β”‚   └── src/
β”‚       β”œβ”€β”€ models/
β”‚       β”‚   β”œβ”€β”€ spec.dart          # Spec, Rule, WidgetAlias, WidgetRole
β”‚       β”‚   β”œβ”€β”€ ethos_config.dart  # EthosConfig, ColorAlias, RuleOverride
β”‚       β”‚   └── coverage_report.dart  # CoverageReport, RuleCoverage, Finding
β”‚       β”œβ”€β”€ specs/v1/
β”‚       β”‚   β”œβ”€β”€ wcag_2_2.yaml          # Source spec β€” edit this
β”‚       β”‚   └── wcag_2_2_embedded.dart # Generated constant β€” do not edit
β”‚       └── analyzer/
β”‚           β”œβ”€β”€ coverage_analyzer.dart  # Standard engine
β”‚           β”œβ”€β”€ spec_loader.dart
β”‚           β”œβ”€β”€ detector_registry.dart
β”‚           β”œβ”€β”€ rule_detector.dart      # RuleDetector interface
β”‚           β”œβ”€β”€ ast/widget_visitor.dart
β”‚           β”œβ”€β”€ utils/
β”‚           β”‚   β”œβ”€β”€ color_resolver.dart
β”‚           β”‚   └── theme_extractor.dart
β”‚           β”œβ”€β”€ detectors/              # 7 standard detectors
β”‚           β”‚   β”œβ”€β”€ semantic_labels_detector.dart
β”‚           β”‚   β”œβ”€β”€ contrast_detector.dart
β”‚           β”‚   β”œβ”€β”€ touch_target_detector.dart
β”‚           β”‚   β”œβ”€β”€ keyboard_detector.dart
β”‚           β”‚   β”œβ”€β”€ focus_order_detector.dart
β”‚           β”‚   β”œβ”€β”€ non_text_content_detector.dart
β”‚           β”‚   └── resize_text_detector.dart
β”‚           β”œβ”€β”€ deep/
β”‚           β”‚   β”œβ”€β”€ deep_analyzer.dart       # Deep engine (Stream)
β”‚           β”‚   β”œβ”€β”€ deep_detector.dart       # DeepDetector interface
β”‚           β”‚   β”œβ”€β”€ analysis_progress.dart   # Sealed class: 6 event types
β”‚           β”‚   β”œβ”€β”€ resolved_file.dart       # ResolvedFile + ProjectIndex
β”‚           β”‚   └── detectors/
β”‚           β”‚       β”œβ”€β”€ cross_file_semantic_labels_detector.dart
β”‚           β”‚       └── resolved_contrast_detector.dart
β”‚           β”œβ”€β”€ watch/
β”‚           β”‚   └── watch_engine.dart        # Incremental cache + ReportDiff
β”‚           └── init/
β”‚               β”œβ”€β”€ widget_discovery.dart    # Scans for custom widgets/colors
β”‚               └── ethos_yaml_generator.dart # Generates starter ethos.yaml
β”œβ”€β”€ example/
β”‚   β”œβ”€β”€ main.dart
β”‚   └── fixtures/
β”‚       β”œβ”€β”€ ethos.yaml
β”‚       └── lib/
└── tool/
    └── embed_spec.dart

Roadmap #

v1.0.0 #

  • Animation preferences detector (wcag_2_3_1) β€” flag AnimationController and transitions that don't respect MediaQuery.disableAnimations.
  • Configurable rule subset β€” run only the rules you care about.
  • Stable public API with full WCAG 2.2 Level AA coverage.

Contributing #

Contributions are welcome. High-value areas:

  • Additional WCAG 2.2 detectors.
  • Improved theme/$styles resolution for the contrast rule.
  • CI/CD integration examples (GitHub Actions, GitLab CI).

License #

Apache-2.0 β€” see LICENSE.

Author #

@gearscrafter β€” Mobile Developer.

Resources #


Made with ❀️ for inclusive Flutter apps.

3
likes
0
points
60
downloads

Publisher

verified publishergearscrafter.dev

Weekly Downloads

Measure accessibility coverage in Flutter apps using WCAG 2.2 specifications with Spec-Driven Development.

Repository (GitHub)
View/report issues

Topics

#accessibility #wcag #flutter #testing #cli

License

unknown (license)

Dependencies

analyzer, args, collection, path, yaml

More

Packages that depend on ethos