Compare commits

...

2 commits

Author SHA1 Message Date
flemming-it
87afa4dc05 feat(docs): in-place help pattern — explain a surface where it happens
Some checks failed
Security / Security check (push) Failing after 2s
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 <stefan.a.flemming@googlemail.com>
2026-07-20 02:00:56 +02:00
flemming-it
5a3f00bb2c 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/<slug>[_de].md files. Exposes
kKnownDocSlugs for the test.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-20 02:00:56 +02:00
15 changed files with 616 additions and 15 deletions

View file

@ -6,6 +6,24 @@ lockstep.
## Unreleased ## 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) ### Security (0.75.0)
- **Sealed-area names are confidential by default.** The workspace - **Sealed-area names are confidential by default.** The workspace

View file

@ -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/<name>-<theme>.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<void> _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);
});
}
}

View file

@ -4,7 +4,7 @@
/// Studio's own build version. Bump on every UI release so the /// Studio's own build version. Bump on every UI release so the
/// running app self-identifies. /// running app self-identifies.
const String kStudioVersion = '0.75.0'; const String kStudioVersion = '0.76.0';
const String kProductName = 'Ch∆In Studio'; const String kProductName = 'Ch∆In Studio';
const String kVendorName = 'Flemming.AI (F∆I)'; const String kVendorName = 'Flemming.AI (F∆I)';

View file

@ -50,6 +50,10 @@
"welcomeDocFlowsBlurb": "YAML-Grundlagen, Templating-Referenz, das Extract→Summarize-Beispiel.", "welcomeDocFlowsBlurb": "YAML-Grundlagen, Templating-Referenz, das Extract→Summarize-Beispiel.",
"welcomeDocApprovalsTitle": "Freigaben", "welcomeDocApprovalsTitle": "Freigaben",
"welcomeDocApprovalsBlurb": "Human-in-the-Loop-Checkpoints — wann nutzen, wie das Audit-Log sie protokolliert.", "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", "helpTooltip": "Hilfe",
"errApprovalRejected": "Freigabe vom Reviewer abgelehnt.", "errApprovalRejected": "Freigabe vom Reviewer abgelehnt.",
"errApprovalRejectedHint": "Der Reviewer hat den Step abgelehnt. Begründung steht im Audit-Log.", "errApprovalRejectedHint": "Der Reviewer hat den Step abgelehnt. Begründung steht im Audit-Log.",
@ -104,6 +108,7 @@
"sidebarSettingsTooltip": "Einstellungen (⌘;)", "sidebarSettingsTooltip": "Einstellungen (⌘;)",
"sidebarChannelTooltip": "Aktiver Kanal — klicken zum Wechseln.\nproduction = stabil · beta = Vorabversion · dev = laufend · local = Arbeitsumgebung", "sidebarChannelTooltip": "Aktiver Kanal — klicken zum Wechseln.\nproduction = stabil · beta = Vorabversion · dev = laufend · local = Arbeitsumgebung",
"buttonCancel": "Abbrechen", "buttonCancel": "Abbrechen",
"buttonLearnMore": "Mehr erfahren",
"buttonSave": "Speichern", "buttonSave": "Speichern",
"buttonClose": "Schließen", "buttonClose": "Schließen",
"buttonRetry": "Erneut versuchen", "buttonRetry": "Erneut versuchen",
@ -1764,6 +1769,8 @@
"federationCapabilities": "{count} angebotene Fähigkeiten", "federationCapabilities": "{count} angebotene Fähigkeiten",
"federationAddDialogTitle": "Registrierungs-Token ausstellen", "federationAddDialogTitle": "Registrierungs-Token ausstellen",
"federationNameLabel": "Satelliten-Name", "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", "federationIssueButton": "Ausstellen",
"federationIssueFailed": "Token konnte nicht ausgestellt werden: {error}", "federationIssueFailed": "Token konnte nicht ausgestellt werden: {error}",
"federationEnrollmentTitle": "{name} einrichten", "federationEnrollmentTitle": "{name} einrichten",

View file

@ -58,6 +58,10 @@
"welcomeDocFlowsBlurb": "YAML basics, templating reference, the extract→summarize example.", "welcomeDocFlowsBlurb": "YAML basics, templating reference, the extract→summarize example.",
"welcomeDocApprovalsTitle": "Approvals", "welcomeDocApprovalsTitle": "Approvals",
"welcomeDocApprovalsBlurb": "Human-in-the-loop checkpoints — when to use them, how the audit log records them.", "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", "helpTooltip": "Help",
"errApprovalRejected": "Approval rejected by reviewer.", "errApprovalRejected": "Approval rejected by reviewer.",
"errApprovalRejectedHint": "The reviewer marked this step as rejected. Check the audit log for the reviewer's reason.", "errApprovalRejectedHint": "The reviewer marked this step as rejected. Check the audit log for the reviewer's reason.",
@ -112,6 +116,7 @@
"sidebarSettingsTooltip": "Settings (⌘;)", "sidebarSettingsTooltip": "Settings (⌘;)",
"sidebarChannelTooltip": "Active channel — click to switch.\nproduction = stable · beta = pre-release · dev = rolling · local = workspace", "sidebarChannelTooltip": "Active channel — click to switch.\nproduction = stable · beta = pre-release · dev = rolling · local = workspace",
"buttonCancel": "Cancel", "buttonCancel": "Cancel",
"buttonLearnMore": "Learn more",
"buttonSave": "Save", "buttonSave": "Save",
"buttonClose": "Close", "buttonClose": "Close",
"buttonRetry": "Retry", "buttonRetry": "Retry",
@ -1793,6 +1798,8 @@
}, },
"federationAddDialogTitle": "Issue an enrollment token", "federationAddDialogTitle": "Issue an enrollment token",
"federationNameLabel": "Satellite name", "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", "federationIssueButton": "Issue",
"federationIssueFailed": "Could not issue token: {error}", "federationIssueFailed": "Could not issue token: {error}",
"@federationIssueFailed": { "@federationIssueFailed": {

View file

@ -398,6 +398,30 @@ abstract class AppLocalizations {
/// **'Human-in-the-loop checkpoints — when to use them, how the audit log records them.'** /// **'Human-in-the-loop checkpoints — when to use them, how the audit log records them.'**
String get welcomeDocApprovalsBlurb; 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. /// No description provided for @helpTooltip.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -680,6 +704,12 @@ abstract class AppLocalizations {
/// **'Cancel'** /// **'Cancel'**
String get buttonCancel; String get buttonCancel;
/// No description provided for @buttonLearnMore.
///
/// In en, this message translates to:
/// **'Learn more'**
String get buttonLearnMore;
/// No description provided for @buttonSave. /// No description provided for @buttonSave.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -5347,6 +5377,18 @@ abstract class AppLocalizations {
/// **'Satellite name'** /// **'Satellite name'**
String get federationNameLabel; 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. /// No description provided for @federationIssueButton.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -180,6 +180,20 @@ class AppLocalizationsDe extends AppLocalizations {
String get welcomeDocApprovalsBlurb => String get welcomeDocApprovalsBlurb =>
'Human-in-the-Loop-Checkpoints — wann nutzen, wie das Audit-Log sie protokolliert.'; '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 @override
String get helpTooltip => 'Hilfe'; String get helpTooltip => 'Hilfe';
@ -338,6 +352,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get buttonCancel => 'Abbrechen'; String get buttonCancel => 'Abbrechen';
@override
String get buttonLearnMore => 'Mehr erfahren';
@override @override
String get buttonSave => 'Speichern'; String get buttonSave => 'Speichern';
@ -3164,6 +3181,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get federationNameLabel => 'Satelliten-Name'; 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 @override
String get federationIssueButton => 'Ausstellen'; String get federationIssueButton => 'Ausstellen';

View file

@ -181,6 +181,20 @@ class AppLocalizationsEn extends AppLocalizations {
String get welcomeDocApprovalsBlurb => String get welcomeDocApprovalsBlurb =>
'Human-in-the-loop checkpoints — when to use them, how the audit log records them.'; '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 @override
String get helpTooltip => 'Help'; String get helpTooltip => 'Help';
@ -338,6 +352,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get buttonCancel => 'Cancel'; String get buttonCancel => 'Cancel';
@override
String get buttonLearnMore => 'Learn more';
@override @override
String get buttonSave => 'Save'; String get buttonSave => 'Save';
@ -3159,6 +3176,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get federationNameLabel => 'Satellite name'; 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 @override
String get federationIssueButton => 'Issue'; String get federationIssueButton => 'Issue';

View file

@ -61,13 +61,38 @@ class _FederationPageState extends State<FederationPage> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: Text(l.federationAddDialogTitle), title: Text(l.federationAddDialogTitle),
content: TextField( 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, controller: controller,
autofocus: true, autofocus: true,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: l.federationNameLabel,
hintText: 'satellite-a', hintText: 'satellite-a',
border: const OutlineInputBorder(), border: OutlineInputBorder(),
isDense: true,
),
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
),
],
), ),
), ),
actions: [ actions: [

View file

@ -1048,14 +1048,28 @@ class _DocEntry {
final IconData icon; final IconData icon;
final String Function(AppLocalizations) title; final String Function(AppLocalizations) title;
final String Function(AppLocalizations) blurb; 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({ const _DocEntry({
required this.slug, required this.slug,
required this.icon, required this.icon,
required this.title, required this.title,
required this.blurb, required this.blurb,
this.onWelcome = true,
}); });
} }
/// Every doc topic Studio can open in-app. The slug maps to
/// `assets/docs/<slug>[_<locale>].md`. Adding a page help button
/// (`showFaiDoc(context, '<slug>')`) 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>[ final List<_DocEntry> _kDocs = <_DocEntry>[
_DocEntry( _DocEntry(
slug: 'architecture', slug: 'architecture',
@ -1087,17 +1101,47 @@ final List<_DocEntry> _kDocs = <_DocEntry>[
title: (l) => l.welcomeDocApprovalsTitle, title: (l) => l.welcomeDocApprovalsTitle,
blurb: (l) => l.welcomeDocApprovalsBlurb, 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<String> kKnownDocSlugs = _kDocs.map((d) => d.slug).toSet();
/// Public entry-point for the doc-reader sheet. Pass a slug /// Public entry-point for the doc-reader sheet. Pass a slug
/// ("approvals", "audit", "security", "architecture", "flows") /// ("approvals", "audit", "security", "architecture", "flows")
/// and the bottom sheet opens with the localized markdown. /// and the bottom sheet opens with the localized markdown.
/// Returns null when the slug isn't registered — caller can /// Returns null when the slug isn't registered — caller can
/// log + show a SnackBar. /// log + show a SnackBar.
Future<void>? showFaiDoc(BuildContext context, String slug) { Future<void>? 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( final entry = _kDocs.firstWhere(
(d) => d.slug == slug, (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); return _DocReaderSheet.show(context, entry);
} }
@ -1130,9 +1174,9 @@ class _DocsRow extends StatelessWidget {
if (!twoCols) { if (!twoCols) {
return Column( return Column(
children: [ 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), 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 // card spans the full width so it reads as intentional
// rather than a lonely half-box with dead space beside it. // rather than a lonely half-box with dead space beside it.
final rows = <Widget>[]; final rows = <Widget>[];
for (var i = 0; i < _kDocs.length; i += 2) { for (var i = 0; i < _kWelcomeDocs.length; i += 2) {
if (rows.isNotEmpty) { if (rows.isNotEmpty) {
rows.add(const SizedBox(height: ChainSpace.md)); rows.add(const SizedBox(height: ChainSpace.md));
} }
final left = _kDocs[i]; final left = _kWelcomeDocs[i];
final right = i + 1 < _kDocs.length ? _kDocs[i + 1] : null; final right = i + 1 < _kWelcomeDocs.length ? _kWelcomeDocs[i + 1] : null;
if (right == null) { if (right == null) {
rows.add(_DocCard(entry: left)); rows.add(_DocCard(entry: left));
} else { } else {

View file

@ -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<ChainFieldHelp> createState() => _ChainFieldHelpState();
}
class _ChainFieldHelpState extends State<ChainFieldHelp> {
final _tooltipKey = GlobalKey<TooltipState>();
@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),
],
);
}
}

View file

@ -11,6 +11,7 @@ export 'chain_delta_mark.dart';
export 'chain_empty_state.dart'; export 'chain_empty_state.dart';
export 'chain_en_badge.dart'; export 'chain_en_badge.dart';
export 'chain_error_box.dart'; export 'chain_error_box.dart';
export 'chain_field_help.dart';
export 'chain_segments.dart'; export 'chain_segments.dart';
export 'hub_load_error_view.dart'; export 'hub_load_error_view.dart';
export 'chain_flow_output.dart'; export 'chain_flow_output.dart';

View file

@ -1,7 +1,7 @@
name: chain_studio name: chain_studio
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
publish_to: 'none' publish_to: 'none'
version: 0.75.0 version: 0.76.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta

View file

@ -0,0 +1,70 @@
// Doc-help wiring guard a page help button must open the RIGHT
// topic. Every `showFaiDoc(context, '<slug>')` call in lib/ needs:
//
// 1. a registered _DocEntry (exposed as kKnownDocSlugs), and
// 2. the backing assets assets/docs/<slug>.md + <slug>_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 = <String>[];
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<String> _showFaiDocSlugsInLib() {
final re = RegExp(r'''showFaiDoc\(\s*context\s*,\s*['"]([a-z0-9_-]+)['"]''');
final slugs = <String>{};
final dir = Directory('lib');
for (final f in dir.listSync(recursive: true).whereType<File>()) {
if (!f.path.endsWith('.dart')) continue;
for (final m in re.allMatches(f.readAsStringSync())) {
slugs.add(m.group(1)!);
}
}
return slugs;
}

89
test/field_help_test.dart Normal file
View file

@ -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);
});
}