fix(ui): WCAG light-theme accent, overflow-safe audit page, formal address
Some checks are pending
Security / Security check (push) Waiting to run
Some checks are pending
Security / Security check (push) Waiting to run
Accessibility/responsive audit pass with two new permanent test gates (test/a11y_test.dart: WCAG text contrast + labeled tap targets on every page in both themes; test/responsive_test.dart: no layout overflow at 800/960/1280/1920 px). Findings fixed: - Light theme primary/tertiary sky-500 → sky-700: white text on the lighter accent only reached 2.8:1 (welcome CTA, active sidebar label); sky-700 clears WCAG AA at ~5.9:1. Dark theme unchanged (already compliant). FABs now follow the same accent instead of Material 3's washed-out tonal default. - Audit page: filter chips collapse into a checkmark popup menu below 900 px window width (app bar overflowed); the live-status bar's left text is now Expanded with ellipsis so the row can shrink, and the disconnected state's copyable error gets the full remaining width. - German strings now use formal address consistently (~20 strings still used du-forms next to Sie-forms on welcome/setup), the audit event-type chip "Step" is "Schritt", and the doctor page's event count pluralises correctly in both languages. flutter analyze clean, 64 tests green. Screenshot pass light+dark via the guide-shots harness (verified parity, no overflows). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
efaa089454
commit
da58125f20
9 changed files with 339 additions and 95 deletions
98
test/a11y_test.dart
Normal file
98
test/a11y_test.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Accessibility gate for every sidebar page, light AND dark:
|
||||
//
|
||||
// * textContrastGuideline — WCAG AA text contrast (>= 4.5:1 for
|
||||
// normal text, >= 3:1 for large text) on whatever the page
|
||||
// renders without a hub (empty/offline states included; those
|
||||
// are exactly the states a fresh operator sees first).
|
||||
// * labeledTapTargetGuideline — every tappable target exposes a
|
||||
// semantic label, so icon-only buttons must carry a tooltip or
|
||||
// Semantics label. Screen-reader users get a name for every
|
||||
// action.
|
||||
//
|
||||
// The Android/iOS tap-target SIZE guidelines are deliberately not
|
||||
// applied: Studio is a desktop app driven by pointer, and Flutter's
|
||||
// desktop defaults (e.g. 34px DropdownMenu items) fail the 48px
|
||||
// mobile rule by design.
|
||||
//
|
||||
// Navigation mirrors sidebar_test.dart: pump StudioApp directly,
|
||||
// tap the sidebar destination keys, pump a fixed duration (the app
|
||||
// holds long-lived timers, so pumpAndSettle never settles).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:chain_studio/data/hub.dart';
|
||||
import 'package:chain_studio/main.dart';
|
||||
|
||||
const _destinations = <String>[
|
||||
'welcome',
|
||||
'store',
|
||||
'doctor',
|
||||
'flows',
|
||||
'audit',
|
||||
'approvals',
|
||||
'runs',
|
||||
'federation',
|
||||
];
|
||||
|
||||
void main() {
|
||||
for (final mode in [ThemeModeValue.light, ThemeModeValue.dark]) {
|
||||
testWidgets('all pages meet a11y guidelines — ${mode.name}',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
// Layout correctness across sizes is responsive_test.dart's
|
||||
// job; this suite audits colors and labels at a normal size.
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
final handle = tester.ensureSemantics();
|
||||
await tester.pumpWidget(
|
||||
StudioApp(
|
||||
initialThemeMode: mode,
|
||||
initialLocale: const Locale('de'),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
// Collect violations across ALL pages before failing, so one
|
||||
// regression does not mask the rest of the sweep.
|
||||
final violations = <String>[];
|
||||
for (final id in _destinations) {
|
||||
await tester.tap(find.byKey(ValueKey('sidebar-item-$id')));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
for (final (guideline, what) in [
|
||||
(textContrastGuideline, 'text contrast'),
|
||||
(labeledTapTargetGuideline, 'unlabeled tap targets'),
|
||||
]) {
|
||||
final result = await guideline.evaluate(tester);
|
||||
if (!result.passed) {
|
||||
final reason = result.reason ?? '';
|
||||
// Known evaluator false positive: the workspace switcher
|
||||
// wraps its trigger in an OverlayPortal + Tooltip, which
|
||||
// makes the contrast guideline lose the text render and
|
||||
// measure the chip's own fill (#27272A) against the page
|
||||
// canvas instead. The actual label text is onSurface on
|
||||
// surfaceContainerHighest (>10:1). Skip exactly that node.
|
||||
if (what == 'text contrast' &&
|
||||
reason.contains('_OverlayPortalState') &&
|
||||
reason.contains('Arbeitsbereich')) {
|
||||
continue;
|
||||
}
|
||||
violations.add('page "$id" (${mode.name}) $what: $reason');
|
||||
}
|
||||
}
|
||||
}
|
||||
handle.dispose();
|
||||
// Tear the app down explicitly and drain in-flight one-shot
|
||||
// timers (status polls, tooltip delays) — the binding asserts
|
||||
// !timersPending after the test body.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(minutes: 1));
|
||||
expect(
|
||||
violations,
|
||||
isEmpty,
|
||||
reason: 'a11y violations:\n${violations.join('\n\n')}',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue