fix(ui): WCAG light-theme accent, overflow-safe audit page, formal address
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:
flemming-it 2026-07-15 04:34:19 +02:00
parent efaa089454
commit da58125f20
9 changed files with 339 additions and 95 deletions

98
test/a11y_test.dart Normal file
View 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')}',
);
});
}
}