From 5a3f00bb2c82dbedf3dab11c00712ebbf94d498a Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 20 Jul 2026 02:00:56 +0200 Subject: [PATCH 1/2] fix(docs): page help buttons open the right topic + wiring guard The Federation and Runs help buttons opened the architecture doc: federation.md/runs.md existed as assets but had no _DocEntry, so showFaiDoc resolved the unknown slug to _kDocs.first. Register both (onWelcome: false, so they don't clutter the newcomer grid but are reachable), split the Welcome grid onto the curated subset, and make the unknown-slug fallback assert in debug instead of silently opening the wrong topic. Guard (no-bugfix-without-a-guard): doc_help_wiring_test.dart scans lib/ for every showFaiDoc('slug') call and asserts each has a registered entry AND both assets/docs/[_de].md files. Exposes kKnownDocSlugs for the test. Signed-off-by: flemming-it --- lib/l10n/app_de.arb | 7 +++ lib/l10n/app_en.arb | 7 +++ lib/l10n/app_localizations.dart | 42 ++++++++++++++++++ lib/l10n/app_localizations_de.dart | 25 +++++++++++ lib/l10n/app_localizations_en.dart | 25 +++++++++++ lib/pages/welcome.dart | 56 +++++++++++++++++++++--- test/doc_help_wiring_test.dart | 70 ++++++++++++++++++++++++++++++ 7 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 test/doc_help_wiring_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c35637c..822947f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -50,6 +50,10 @@ "welcomeDocFlowsBlurb": "YAML-Grundlagen, Templating-Referenz, das Extract→Summarize-Beispiel.", "welcomeDocApprovalsTitle": "Freigaben", "welcomeDocApprovalsBlurb": "Human-in-the-Loop-Checkpoints — wann nutzen, wie das Audit-Log sie protokolliert.", + "welcomeDocFederationTitle": "Föderation", + "welcomeDocFederationBlurb": "Weitere Hubs (Satelliten) anbinden, damit ihre Fähigkeiten hier nutzbar werden — was ein Satellit ist und wie die Anbindung abläuft.", + "welcomeDocRunsTitle": "Hintergrund-Läufe", + "welcomeDocRunsBlurb": "Flows, die im Hintergrund weiterlaufen — wozu, wie man die Funktion einschaltet und die Läufe überwacht.", "helpTooltip": "Hilfe", "errApprovalRejected": "Freigabe vom Reviewer abgelehnt.", "errApprovalRejectedHint": "Der Reviewer hat den Step abgelehnt. Begründung steht im Audit-Log.", @@ -104,6 +108,7 @@ "sidebarSettingsTooltip": "Einstellungen (⌘;)", "sidebarChannelTooltip": "Aktiver Kanal — klicken zum Wechseln.\nproduction = stabil · beta = Vorabversion · dev = laufend · local = Arbeitsumgebung", "buttonCancel": "Abbrechen", + "buttonLearnMore": "Mehr erfahren", "buttonSave": "Speichern", "buttonClose": "Schließen", "buttonRetry": "Erneut versuchen", @@ -1764,6 +1769,8 @@ "federationCapabilities": "{count} angebotene Fähigkeiten", "federationAddDialogTitle": "Registrierungs-Token ausstellen", "federationNameLabel": "Satelliten-Name", + "federationAddIntro": "Ein Satellit ist ein weiterer Hub, den dieser Hub anbindet — dessen Fähigkeiten werden dann hier in Flows nutzbar. Sie vergeben einen Namen und erhalten ein einmaliges Token, mit dem sich der Satellit bei diesem Hub anmeldet.", + "federationNameHelp": "Frei wählbarer Name, um diesen Satelliten in der Liste wiederzuerkennen (z. B. Standort oder Zweck). Rein zur Anzeige — ändert nichts an der Technik.", "federationIssueButton": "Ausstellen", "federationIssueFailed": "Token konnte nicht ausgestellt werden: {error}", "federationEnrollmentTitle": "{name} einrichten", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8a887a4..874e9ac 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -58,6 +58,10 @@ "welcomeDocFlowsBlurb": "YAML basics, templating reference, the extract→summarize example.", "welcomeDocApprovalsTitle": "Approvals", "welcomeDocApprovalsBlurb": "Human-in-the-loop checkpoints — when to use them, how the audit log records them.", + "welcomeDocFederationTitle": "Federation", + "welcomeDocFederationBlurb": "Connect other hubs (satellites) so their capabilities become usable here — what a satellite is and how enrolment works.", + "welcomeDocRunsTitle": "Background runs", + "welcomeDocRunsBlurb": "Flows that keep working in the background — what they are for, how to switch the feature on and monitor the runs.", "helpTooltip": "Help", "errApprovalRejected": "Approval rejected by reviewer.", "errApprovalRejectedHint": "The reviewer marked this step as rejected. Check the audit log for the reviewer's reason.", @@ -112,6 +116,7 @@ "sidebarSettingsTooltip": "Settings (⌘;)", "sidebarChannelTooltip": "Active channel — click to switch.\nproduction = stable · beta = pre-release · dev = rolling · local = workspace", "buttonCancel": "Cancel", + "buttonLearnMore": "Learn more", "buttonSave": "Save", "buttonClose": "Close", "buttonRetry": "Retry", @@ -1793,6 +1798,8 @@ }, "federationAddDialogTitle": "Issue an enrollment token", "federationNameLabel": "Satellite name", + "federationAddIntro": "A satellite is another hub this hub connects to — its capabilities then become usable here in flows. You give it a name and receive a one-time token the satellite uses to enrol with this hub.", + "federationNameHelp": "A free-form name to recognize this satellite in the list (e.g. its location or purpose). Display only — it changes nothing technical.", "federationIssueButton": "Issue", "federationIssueFailed": "Could not issue token: {error}", "@federationIssueFailed": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 65bd2a7..c3c747f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -398,6 +398,30 @@ abstract class AppLocalizations { /// **'Human-in-the-loop checkpoints — when to use them, how the audit log records them.'** String get welcomeDocApprovalsBlurb; + /// No description provided for @welcomeDocFederationTitle. + /// + /// In en, this message translates to: + /// **'Federation'** + String get welcomeDocFederationTitle; + + /// No description provided for @welcomeDocFederationBlurb. + /// + /// In en, this message translates to: + /// **'Connect other hubs (satellites) so their capabilities become usable here — what a satellite is and how enrolment works.'** + String get welcomeDocFederationBlurb; + + /// No description provided for @welcomeDocRunsTitle. + /// + /// In en, this message translates to: + /// **'Background runs'** + String get welcomeDocRunsTitle; + + /// No description provided for @welcomeDocRunsBlurb. + /// + /// In en, this message translates to: + /// **'Flows that keep working in the background — what they are for, how to switch the feature on and monitor the runs.'** + String get welcomeDocRunsBlurb; + /// No description provided for @helpTooltip. /// /// In en, this message translates to: @@ -680,6 +704,12 @@ abstract class AppLocalizations { /// **'Cancel'** String get buttonCancel; + /// No description provided for @buttonLearnMore. + /// + /// In en, this message translates to: + /// **'Learn more'** + String get buttonLearnMore; + /// No description provided for @buttonSave. /// /// In en, this message translates to: @@ -5347,6 +5377,18 @@ abstract class AppLocalizations { /// **'Satellite name'** String get federationNameLabel; + /// No description provided for @federationAddIntro. + /// + /// In en, this message translates to: + /// **'A satellite is another hub this hub connects to — its capabilities then become usable here in flows. You give it a name and receive a one-time token the satellite uses to enrol with this hub.'** + String get federationAddIntro; + + /// No description provided for @federationNameHelp. + /// + /// In en, this message translates to: + /// **'A free-form name to recognize this satellite in the list (e.g. its location or purpose). Display only — it changes nothing technical.'** + String get federationNameHelp; + /// No description provided for @federationIssueButton. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a2d5768..a72ae77 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -180,6 +180,20 @@ class AppLocalizationsDe extends AppLocalizations { String get welcomeDocApprovalsBlurb => 'Human-in-the-Loop-Checkpoints — wann nutzen, wie das Audit-Log sie protokolliert.'; + @override + String get welcomeDocFederationTitle => 'Föderation'; + + @override + String get welcomeDocFederationBlurb => + 'Weitere Hubs (Satelliten) anbinden, damit ihre Fähigkeiten hier nutzbar werden — was ein Satellit ist und wie die Anbindung abläuft.'; + + @override + String get welcomeDocRunsTitle => 'Hintergrund-Läufe'; + + @override + String get welcomeDocRunsBlurb => + 'Flows, die im Hintergrund weiterlaufen — wozu, wie man die Funktion einschaltet und die Läufe überwacht.'; + @override String get helpTooltip => 'Hilfe'; @@ -338,6 +352,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get buttonCancel => 'Abbrechen'; + @override + String get buttonLearnMore => 'Mehr erfahren'; + @override String get buttonSave => 'Speichern'; @@ -3164,6 +3181,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get federationNameLabel => 'Satelliten-Name'; + @override + String get federationAddIntro => + 'Ein Satellit ist ein weiterer Hub, den dieser Hub anbindet — dessen Fähigkeiten werden dann hier in Flows nutzbar. Sie vergeben einen Namen und erhalten ein einmaliges Token, mit dem sich der Satellit bei diesem Hub anmeldet.'; + + @override + String get federationNameHelp => + 'Frei wählbarer Name, um diesen Satelliten in der Liste wiederzuerkennen (z. B. Standort oder Zweck). Rein zur Anzeige — ändert nichts an der Technik.'; + @override String get federationIssueButton => 'Ausstellen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6b9c2b5..5e766b5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -181,6 +181,20 @@ class AppLocalizationsEn extends AppLocalizations { String get welcomeDocApprovalsBlurb => 'Human-in-the-loop checkpoints — when to use them, how the audit log records them.'; + @override + String get welcomeDocFederationTitle => 'Federation'; + + @override + String get welcomeDocFederationBlurb => + 'Connect other hubs (satellites) so their capabilities become usable here — what a satellite is and how enrolment works.'; + + @override + String get welcomeDocRunsTitle => 'Background runs'; + + @override + String get welcomeDocRunsBlurb => + 'Flows that keep working in the background — what they are for, how to switch the feature on and monitor the runs.'; + @override String get helpTooltip => 'Help'; @@ -338,6 +352,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get buttonCancel => 'Cancel'; + @override + String get buttonLearnMore => 'Learn more'; + @override String get buttonSave => 'Save'; @@ -3159,6 +3176,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get federationNameLabel => 'Satellite name'; + @override + String get federationAddIntro => + 'A satellite is another hub this hub connects to — its capabilities then become usable here in flows. You give it a name and receive a one-time token the satellite uses to enrol with this hub.'; + + @override + String get federationNameHelp => + 'A free-form name to recognize this satellite in the list (e.g. its location or purpose). Display only — it changes nothing technical.'; + @override String get federationIssueButton => 'Issue'; diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 09e975b..96a9fd7 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -1048,14 +1048,28 @@ class _DocEntry { final IconData icon; final String Function(AppLocalizations) title; final String Function(AppLocalizations) blurb; + + /// Whether this entry is shown as a card in the Welcome page's + /// doc grid. Every entry is reachable via [showFaiDoc] regardless; + /// `onWelcome: false` keeps advanced topics (federation, runs) out + /// of the newcomer grid while still wiring their page help buttons. + final bool onWelcome; + const _DocEntry({ required this.slug, required this.icon, required this.title, required this.blurb, + this.onWelcome = true, }); } +/// Every doc topic Studio can open in-app. The slug maps to +/// `assets/docs/[_].md`. Adding a page help button +/// (`showFaiDoc(context, '')`) requires a matching entry here +/// AND the asset files — `doc_help_wiring_test.dart` enforces both, +/// so a help button can never silently fall back to the wrong topic +/// again (federation/runs used to resolve to architecture). final List<_DocEntry> _kDocs = <_DocEntry>[ _DocEntry( slug: 'architecture', @@ -1087,17 +1101,47 @@ final List<_DocEntry> _kDocs = <_DocEntry>[ title: (l) => l.welcomeDocApprovalsTitle, blurb: (l) => l.welcomeDocApprovalsBlurb, ), + _DocEntry( + slug: 'federation', + icon: Icons.hub_outlined, + title: (l) => l.welcomeDocFederationTitle, + blurb: (l) => l.welcomeDocFederationBlurb, + onWelcome: false, + ), + _DocEntry( + slug: 'runs', + icon: Icons.rocket_launch_outlined, + title: (l) => l.welcomeDocRunsTitle, + blurb: (l) => l.welcomeDocRunsBlurb, + onWelcome: false, + ), ]; +/// The doc cards shown on the Welcome page (curated newcomer set). +final List<_DocEntry> _kWelcomeDocs = + _kDocs.where((d) => d.onWelcome).toList(); + +/// Slugs Studio can resolve in-app — exposed for the wiring guard +/// test so it can assert every `showFaiDoc` call has a home. +final Set kKnownDocSlugs = _kDocs.map((d) => d.slug).toSet(); + /// Public entry-point for the doc-reader sheet. Pass a slug /// ("approvals", "audit", "security", "architecture", "flows") /// and the bottom sheet opens with the localized markdown. /// Returns null when the slug isn't registered — caller can /// log + show a SnackBar. Future? showFaiDoc(BuildContext context, String slug) { + // A slug with no entry is a wiring bug (doc_help_wiring_test.dart + // guards against it). The fallback keeps the UI alive in release, + // but we assert in debug so the mistake surfaces during + // development rather than silently opening the wrong topic. final entry = _kDocs.firstWhere( (d) => d.slug == slug, - orElse: () => _kDocs.first, + orElse: () { + assert(false, 'showFaiDoc: unknown doc slug "$slug" — register it ' + 'in _kDocs and add assets/docs/$slug[_de].md'); + return _kDocs.first; + }, ); return _DocReaderSheet.show(context, entry); } @@ -1130,9 +1174,9 @@ class _DocsRow extends StatelessWidget { if (!twoCols) { return Column( children: [ - for (var i = 0; i < _kDocs.length; i++) ...[ + for (var i = 0; i < _kWelcomeDocs.length; i++) ...[ if (i > 0) const SizedBox(height: ChainSpace.md), - _DocCard(entry: _kDocs[i]), + _DocCard(entry: _kWelcomeDocs[i]), ], ], ); @@ -1144,12 +1188,12 @@ class _DocsRow extends StatelessWidget { // card spans the full width so it reads as intentional // rather than a lonely half-box with dead space beside it. final rows = []; - for (var i = 0; i < _kDocs.length; i += 2) { + for (var i = 0; i < _kWelcomeDocs.length; i += 2) { if (rows.isNotEmpty) { rows.add(const SizedBox(height: ChainSpace.md)); } - final left = _kDocs[i]; - final right = i + 1 < _kDocs.length ? _kDocs[i + 1] : null; + final left = _kWelcomeDocs[i]; + final right = i + 1 < _kWelcomeDocs.length ? _kWelcomeDocs[i + 1] : null; if (right == null) { rows.add(_DocCard(entry: left)); } else { diff --git a/test/doc_help_wiring_test.dart b/test/doc_help_wiring_test.dart new file mode 100644 index 0000000..6e23251 --- /dev/null +++ b/test/doc_help_wiring_test.dart @@ -0,0 +1,70 @@ +// Doc-help wiring guard — a page help button must open the RIGHT +// topic. Every `showFaiDoc(context, '')` call in lib/ needs: +// +// 1. a registered _DocEntry (exposed as kKnownDocSlugs), and +// 2. the backing assets assets/docs/.md + _de.md. +// +// Without this, a slug with no entry silently fell back to the +// first doc ('architecture'): the Federation and Runs help buttons +// opened the architecture sheet even though federation.md/runs.md +// existed — the author wrote the docs, set the icon, but never +// wired the catalog, and nothing caught it. This test catches that +// whole class (per the no-bugfix-without-a-guard rule). + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/pages/welcome.dart' show kKnownDocSlugs; + +void main() { + final callSlugs = _showFaiDocSlugsInLib(); + + test('lib/ actually calls showFaiDoc somewhere (sanity)', () { + expect( + callSlugs, + isNotEmpty, + reason: 'no showFaiDoc calls found — did the scan regex go stale?', + ); + }); + + test('every showFaiDoc slug is registered in the doc catalog', () { + final unregistered = callSlugs.difference(kKnownDocSlugs); + expect( + unregistered, + isEmpty, + reason: + 'These slugs are opened by a help button but have no _DocEntry, ' + 'so they silently fall back to the wrong topic. Register them in ' + '_kDocs (lib/pages/welcome.dart): $unregistered', + ); + }); + + test('every registered doc slug has both locale assets', () { + final missing = []; + for (final slug in kKnownDocSlugs) { + for (final path in ['assets/docs/$slug.md', 'assets/docs/${slug}_de.md']) { + if (!File(path).existsSync()) missing.add(path); + } + } + expect( + missing, + isEmpty, + reason: 'Registered doc slugs missing their markdown assets: $missing', + ); + }); +} + +/// Scan lib/ for `showFaiDoc(context, 'slug')` and collect the slugs. +Set _showFaiDocSlugsInLib() { + final re = RegExp(r'''showFaiDoc\(\s*context\s*,\s*['"]([a-z0-9_-]+)['"]'''); + final slugs = {}; + final dir = Directory('lib'); + for (final f in dir.listSync(recursive: true).whereType()) { + if (!f.path.endsWith('.dart')) continue; + for (final m in re.allMatches(f.readAsStringSync())) { + slugs.add(m.group(1)!); + } + } + return slugs; +} From 87afa4dc056493358cb26c8172367d45f250a935 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 20 Jul 2026 02:00:56 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(docs):=20in-place=20help=20pattern=20?= =?UTF-8?q?=E2=80=94=20explain=20a=20surface=20where=20it=20happens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ChainInlineHelp (intro strip: what this is + what will happen, with an optional 'Learn more' into the doc sheet) and ChainFieldHelp / ChainFieldLabel (a '?' affordance per field). First applied to the add-satellite dialog, which asked for a bare 'name' with no hint of what a satellite is or does (usertest): it now leads with a plain explanation + a federation 'Learn more', and the name field carries a '?'. Both widgets are quiet by design. Verified: field_help_test covers the widgets + that the dialog explains itself; dialog_shots_test.dart (a reusable headed dialog-capture harness) proved the layout in light + dark. Studio 0.76.0. Signed-off-by: flemming-it --- CHANGELOG.md | 18 +++ integration_test/dialog_shots_test.dart | 79 +++++++++++ lib/data/about_info.dart | 2 +- lib/pages/federation.dart | 39 +++++- lib/widgets/chain_field_help.dart | 169 ++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + pubspec.yaml | 2 +- test/field_help_test.dart | 89 +++++++++++++ 8 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 integration_test/dialog_shots_test.dart create mode 100644 lib/widgets/chain_field_help.dart create mode 100644 test/field_help_test.dart 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); + }); +}