ethos 0.7.0
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.
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.Xautomatically from yourThemeData, and accepts explicitcolor_aliasesfor custom style variables. - β
Deep analysis mode (
--deep) β usesAnalysisContextCollectionto resolve cross-file references, class hierarchies, and type information. EmitsStream<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,InkResponsewith tap-like gestures, plus anyrole: buttonalias fromethos.yaml. - Pass: wrapped in
Semantics(label: '<non-empty literal>')as ancestor or descendant; or the aliaslabel_argis 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
Semanticswrappers.
// β
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:
- Inline literals β
TextStyle(color: Color(0xFF...), backgroundColor: ...). - ThemeData extraction β resolves
theme.textTheme.bodyLargeetc. color_aliasesβ resolves design-system expressions fromethos.yaml.- 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 withsize_guaranteed: true. - Verifiable: custom widget in a
SizedBox/Containerwith 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;
GestureDetectorunderFocus/FocusScope/Shortcuts/KeyboardListener; aliases withkeyboard_ready: true. - Fail:
GestureDetector.onTapwith 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:
Formwidgets, or layouts with 2+ focusable inputs. - Pass: declares
FocusNode,FocusScope,FocusTraversalGroup, orautofocus: 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: '...'), orexcludeFromSemantics: true(decorative); forIcon, a non-empty literalsemanticLabel: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
TextandMediaQuerywidgets that explicitly settextScaleFactorortextScaler. 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, orTextScaler.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) β flagAnimationControllerand transitions that don't respectMediaQuery.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/
$stylesresolution for the contrast rule. - CI/CD integration examples (GitHub Actions, GitLab CI).
License #
Apache-2.0 β see LICENSE.
Author #
@gearscrafter β Mobile Developer.
Resources #
- WCAG 2.2 Quick Reference
- Flutter Accessibility Docs
- Material Design 3 β Accessibility
- WebAIM Contrast Checker
Made with β€οΈ for inclusive Flutter apps.