diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3c785..9c9a7b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ lockstep. ## Unreleased +### Added — explain it where it happens (0.76.0) + +- **In-place help pattern.** New `ChainInlineHelp` (a one-sentence + intro strip that says what a surface is and what will happen, with + an optional "Learn more" link into the doc sheet) and + `ChainFieldHelp` / `ChainFieldLabel` (a "?" affordance for a single + field). First applied to the add-satellite dialog, which used to + ask for a "name" with no hint of what a satellite even is + (usertest): it now leads with a plain explanation + a federation + "Learn more", and the name field carries a "?". +- **Page help opens the RIGHT topic.** The Federation and Runs help + buttons silently fell back to the architecture doc — `federation` + and `runs` had markdown assets but no catalog entry, so + `showFaiDoc` resolved them to the first doc. Both are registered + now; a new `doc_help_wiring_test.dart` asserts every + `showFaiDoc('slug')` call has a registered entry AND both locale + assets, so a help button can never open the wrong topic again. + ### Security (0.75.0) - **Sealed-area names are confidential by default.** The workspace diff --git a/integration_test/dialog_shots_test.dart b/integration_test/dialog_shots_test.dart new file mode 100644 index 0000000..7d70768 --- /dev/null +++ b/integration_test/dialog_shots_test.dart @@ -0,0 +1,79 @@ +// Dialog capture harness — visual proof for dialogs the page-level +// guide harness doesn't reach. Renders the target dialog against +// the scriptable fake hub and writes a PNG per theme via a +// RepaintBoundary (driverless, headed macOS `flutter test`). +// +// flutter test integration_test/dialog_shots_test.dart -d macos +// +// Output: build/dialog-shots/-.png (or $DIALOG_SHOTS_OUT). + +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/federation.dart'; + +import '../test/support/fake_hub.dart'; + +final GlobalKey _shotKey = GlobalKey(); + +String get _outDir => + Platform.environment['DIALOG_SHOTS_OUT'] ?? 'build/dialog-shots'; + +Future _shot(WidgetTester tester, String name) async { + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(const Duration(milliseconds: 150)); + final boundary = + _shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 2.0); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + final file = File('$_outDir/$name.png'); + file.parent.createSync(recursive: true); + file.writeAsBytesSync(bytes!.buffer.asUint8List()); + // ignore: avoid_print + print('dialog-shot: ${file.path}'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + for (final (themeName, mode) in [ + ('light', ThemeMode.light), + ('dark', ThemeMode.dark), + ]) { + testWidgets('add-satellite dialog — $themeName', (tester) async { + SharedPreferences.setMockInitialValues({}); + installFakeHub(); + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + themeMode: mode, + theme: ThemeData.light(useMaterial3: true), + darkTheme: ThemeData.dark(useMaterial3: true), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + // Wrap the whole navigator (incl. the overlay dialogs + // render into) so the capture catches the dialog, not + // just the page beneath it. + builder: (context, child) => + RepaintBoundary(key: _shotKey, child: child), + home: const FederationPage(), + ), + ); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(find.text('Satellit hinzufügen').first); + await tester.pumpAndSettle(); + await _shot(tester, 'add-satellite-$themeName'); + expect(find.textContaining('Ein Satellit ist ein weiterer Hub'), + findsOneWidget); + }); + } +} diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index d618851..f867338 100644 --- a/lib/data/about_info.dart +++ b/lib/data/about_info.dart @@ -4,7 +4,7 @@ /// Studio's own build version. Bump on every UI release so the /// running app self-identifies. -const String kStudioVersion = '0.75.0'; +const String kStudioVersion = '0.76.0'; const String kProductName = 'Ch∆In Studio'; const String kVendorName = 'Flemming.AI (F∆I)'; diff --git a/lib/pages/federation.dart b/lib/pages/federation.dart index d9cdaf7..c4b0b15 100644 --- a/lib/pages/federation.dart +++ b/lib/pages/federation.dart @@ -61,13 +61,38 @@ class _FederationPageState extends State { context: context, builder: (ctx) => AlertDialog( title: Text(l.federationAddDialogTitle), - content: TextField( - controller: controller, - autofocus: true, - decoration: InputDecoration( - labelText: l.federationNameLabel, - hintText: 'satellite-a', - border: const OutlineInputBorder(), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Say what this is and what will happen before asking + // for input — a bare "name" field left the operator + // guessing what a satellite even is (usertest). + ChainInlineHelp( + text: l.federationAddIntro, + icon: Icons.hub_outlined, + onLearnMore: () => showFaiDoc(ctx, 'federation'), + learnMoreLabel: l.buttonLearnMore, + ), + const SizedBox(height: ChainSpace.lg), + ChainFieldLabel( + label: l.federationNameLabel, + help: l.federationNameHelp, + ), + const SizedBox(height: ChainSpace.xs), + TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + hintText: 'satellite-a', + border: OutlineInputBorder(), + isDense: true, + ), + onSubmitted: (v) => Navigator.pop(ctx, v.trim()), + ), + ], ), ), actions: [ diff --git a/lib/widgets/chain_field_help.dart b/lib/widgets/chain_field_help.dart new file mode 100644 index 0000000..9ce8db9 --- /dev/null +++ b/lib/widgets/chain_field_help.dart @@ -0,0 +1,169 @@ +// In-place help — the "explain it where it happens" pattern +// (usertest: the Add-satellite dialog asked for a name with no hint +// of what a satellite is or what the name does). Two pieces: +// +// • ChainInlineHelp — a calm intro strip at the top of a dialog +// or surface: one plain sentence saying what this is and what +// will happen, with an optional "Learn more" link into the +// full doc sheet. +// • ChainFieldHelp — a small "?" affordance to sit next to a +// single field's label; tap/hover reveals a one-line +// explanation. Use it only where a field genuinely needs it. +// +// Both are intentionally quiet: help should be present, not loud. + +import 'package:flutter/material.dart'; + +import '../theme/tokens.dart'; + +/// A one-sentence intro strip for the top of a dialog or panel. +/// [onLearnMore] wires the "Learn more" link to a doc sheet +/// (`showFaiDoc`), shown only when provided. +class ChainInlineHelp extends StatelessWidget { + final String text; + final IconData icon; + final VoidCallback? onLearnMore; + final String? learnMoreLabel; + + const ChainInlineHelp({ + super.key, + required this.text, + this.icon = Icons.info_outline, + this.onLearnMore, + this.learnMoreLabel, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(ChainSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(ChainRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: theme.colorScheme.primary), + const SizedBox(width: ChainSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface, + height: 1.4, + ), + ), + if (onLearnMore != null) ...[ + const SizedBox(height: ChainSpace.xs), + InkWell( + onTap: onLearnMore, + borderRadius: BorderRadius.circular(ChainRadius.sm), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + learnMoreLabel ?? 'Learn more', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 2), + Icon( + Icons.arrow_forward, + size: 12, + color: theme.colorScheme.primary, + ), + ], + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +/// A "?" info affordance for a single field. Sit it next to the +/// field's label; hover shows the [message] as a tooltip, and a +/// tap reveals it too (touch / keyboard users who don't hover). +/// Semantics carry [message] for screen readers. +class ChainFieldHelp extends StatefulWidget { + final String message; + + const ChainFieldHelp({super.key, required this.message}); + + @override + State createState() => _ChainFieldHelpState(); +} + +class _ChainFieldHelpState extends State { + final _tooltipKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Tooltip( + key: _tooltipKey, + message: widget.message, + waitDuration: const Duration(milliseconds: 300), + triggerMode: TooltipTriggerMode.manual, + preferBelow: false, + child: Semantics( + button: true, + label: widget.message, + child: InkResponse( + radius: 14, + // Manual trigger so a tap (not just hover) reveals it. + onTap: () => _tooltipKey.currentState?.ensureTooltipVisible(), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.help_outline, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ); + } +} + +/// A field label with a trailing [ChainFieldHelp]. Convenience for +/// the common "label + ?" row above a TextField. +class ChainFieldLabel extends StatelessWidget { + final String label; + final String help; + + const ChainFieldLabel({super.key, required this.label, required this.help}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + ChainFieldHelp(message: help), + ], + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 1de0fec..9870981 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -11,6 +11,7 @@ export 'chain_delta_mark.dart'; export 'chain_empty_state.dart'; export 'chain_en_badge.dart'; export 'chain_error_box.dart'; +export 'chain_field_help.dart'; export 'chain_segments.dart'; export 'hub_load_error_view.dart'; export 'chain_flow_output.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 01aa360..c6dba7b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: chain_studio description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" publish_to: 'none' -version: 0.75.0 +version: 0.76.0 environment: sdk: ^3.11.0-200.1.beta diff --git a/test/field_help_test.dart b/test/field_help_test.dart new file mode 100644 index 0000000..763e936 --- /dev/null +++ b/test/field_help_test.dart @@ -0,0 +1,89 @@ +// In-place help widgets + the satellite dialog that consumes them. +// The pattern (usertest): explain a surface where it happens — +// an intro strip saying what this is and what will happen, plus a +// per-field "?" for the details. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/federation.dart'; +import 'package:chain_studio/widgets/chain_field_help.dart'; + +import 'support/fake_hub.dart'; + +Widget _host(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + home: Scaffold(body: child), +); + +void main() { + testWidgets('ChainInlineHelp shows the text and fires Learn more', ( + tester, + ) async { + var learned = false; + await tester.pumpWidget( + _host( + ChainInlineHelp( + text: 'Ein Satellit ist ein weiterer Hub.', + onLearnMore: () => learned = true, + learnMoreLabel: 'Mehr erfahren', + ), + ), + ); + expect(find.text('Ein Satellit ist ein weiterer Hub.'), findsOneWidget); + await tester.tap(find.text('Mehr erfahren')); + expect(learned, isTrue); + }); + + testWidgets('ChainInlineHelp hides Learn more when no callback', ( + tester, + ) async { + await tester.pumpWidget( + _host(const ChainInlineHelp(text: 'Nur Text, kein Link.')), + ); + expect(find.text('Nur Text, kein Link.'), findsOneWidget); + expect(find.byIcon(Icons.arrow_forward), findsNothing); + }); + + testWidgets('ChainFieldHelp tap reveals the tooltip message', (tester) async { + await tester.pumpWidget( + _host( + const ChainFieldLabel( + label: 'Satelliten-Name', + help: 'Frei wählbarer Anzeigename.', + ), + ), + ); + // The help text is not shown until the affordance is used. + expect(find.text('Frei wählbarer Anzeigename.'), findsNothing); + await tester.tap(find.byIcon(Icons.help_outline)); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('Frei wählbarer Anzeigename.'), findsOneWidget); + }); + + testWidgets('Add-satellite dialog explains what a satellite is', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + installFakeHub(); + await tester.pumpWidget(_host(const FederationPage())); + await tester.pump(const Duration(milliseconds: 200)); + + // Open the add flow via the AppBar action (the label also + // appears in the empty-state CTA; either opens the dialog). + await tester.tap(find.text('Satellit hinzufügen').first); + await tester.pumpAndSettle(); + + // The intro strip + its Learn-more link are present BEFORE any + // input is asked for — no more bare "name" field. + expect(find.textContaining('Ein Satellit ist ein weiterer Hub'), + findsOneWidget); + expect(find.text('Mehr erfahren'), findsOneWidget); + // Field-level "?" is wired too. + expect(find.byType(ChainFieldHelp), findsWidgets); + }); +}