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; +}