From 4b30589962dd409ecf807ef0d5f9c9b8b5bcb800 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 9 Jul 2026 17:18:40 +0200 Subject: [PATCH 1/3] feat(store): group grid by canonical category + Modules/Studio segment The store grid was a flat jumble. It now renders labelled sections per canonical category (App-Store style, fixed order, with counts), and a top 'Modules | Studio & Themes' segment splits flow modules from Studio plugins/themes. StoreItem carries the hub's canonical_category(+label); falls back to the raw category label for a pre-0.21 hub. Signed-off-by: flemming-it --- lib/data/hub.dart | 18 +++++ lib/pages/store.dart | 162 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 156 insertions(+), 24 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index df4b7fc..a10f431 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -787,6 +787,8 @@ class HubService { provider: e.provider, sourceKind: e.sourceKind, source: e.source, + canonicalCategory: e.canonicalCategory, + canonicalCategoryLabel: e.canonicalCategoryLabel, ), ) .toList(); @@ -1800,6 +1802,20 @@ class StoreItem { /// label and group modules by where they came from. final String source; + /// Normalized category slug from the hub (e.g. "data", + /// "studio-themes"). The whole catalogue is mapped onto a fixed + /// taxonomy by capability namespace, so different stores' free-form + /// labels collapse into one bucket. Studio groups + filters by this, + /// not [category]. Empty from a pre-0.21 hub → fall back to [category]. + final String canonicalCategory; + + /// Human-readable label for [canonicalCategory] (e.g. "Data & Formats"). + final String canonicalCategoryLabel; + + /// True iff this entry is a Studio plugin/theme rather than a flow + /// module — drives the "Modules | Studio & Themes" store segment. + bool get isStudioPlugin => canonicalCategory == 'studio-themes'; + bool get isFederated => kind == 'federated'; const StoreItem({ @@ -1825,5 +1841,7 @@ class StoreItem { required this.provider, this.sourceKind = '', this.source = '', + this.canonicalCategory = '', + this.canonicalCategoryLabel = '', }); } diff --git a/lib/pages/store.dart b/lib/pages/store.dart index d21a7bf..4d38484 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -33,6 +33,12 @@ class _StorePageState extends State { String _category = ''; String _status = ''; + /// Store segment: `false` shows flow Modules, `true` shows Studio + /// plugins + themes. A theme extends the GUI, a module runs in a + /// flow — mixing them in one grid was a big part of the clutter, so + /// they live under a top segment toggle instead. + bool _showStudio = false; + /// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied /// client-side after the hub returns results — the search RPC /// has no source field. @@ -355,6 +361,30 @@ class _StorePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Modules vs Studio plugins/themes — a theme + // extends the GUI, a module runs in a flow. + Padding( + padding: + const EdgeInsets.only(bottom: ChainSpace.md), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + label: Text('Module'), + icon: Icon(Icons.extension_outlined, size: 16), + ), + ButtonSegment( + value: true, + label: Text('Studio & Themes'), + icon: Icon(Icons.palette_outlined, size: 16), + ), + ], + selected: {_showStudio}, + showSelectedIcon: false, + onSelectionChanged: (s) => + setState(() => _showStudio = s.first), + ), + ), if (_aiThinking || _aiAnswer != null || _aiError != null) ...[ @@ -496,6 +526,10 @@ class _StorePageState extends State { /// card and the grid stay coherent. List _applyAllFilters(List items) { var out = _applySourceFilter(items); + // Store segment: flow modules vs Studio plugins/themes. Federated + // entries (MCP/n8n bridges) are never Studio plugins, so they stay + // in the Modules segment. + out = out.where((e) => e.isStudioPlugin == _showStudio).toList(); final ai = _aiMatchedNames; if (ai != null) { out = out.where((e) => ai.contains(e.name)).toList(); @@ -1529,36 +1563,116 @@ class _StoreGrid extends StatelessWidget { required this.onInstall, }); + /// Fixed display order of the canonical categories (mirrors the + /// hub's `Category::all_ordered`) — flow-module categories first, + /// Studio + Other last, so the grouped store reads top-to-bottom + /// like an app store's category rows. + static const List _order = [ + 'documents', + 'text-language', + 'data', + 'ai-llm', + 'web-api', + 'analysis-domain', + 'examples-dev', + 'studio-themes', + 'other', + ]; + @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { const minCardWidth = 360.0; final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4); - // shrinkWrap + NeverScrollable lets the grid sit inside - // the page-level SingleChildScrollView so editorial - // chrome and the grid scroll as one continuous surface - // (App-Store / Play-Store behaviour). Without this, the - // inner grid claims its own scroll viewport and the - // outer Column overflows on small windows. - return GridView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - mainAxisSpacing: ChainSpace.md, - crossAxisSpacing: ChainSpace.md, - mainAxisExtent: 168, - ), - itemCount: items.length, - itemBuilder: (context, i) => _StoreCard( - item: items[i], - locale: locale, - installedVersion: installedVersions[items[i].name], - onTap: () => onTap(items[i]), - onInstall: () => onInstall(items[i]), - ), + + // Group the flat result set by canonical category so the + // store reads like an app store — a labelled section per + // category instead of one jumbled grid. Falls back to the + // raw `category` label for a pre-0.21 hub that sends no + // canonical slug. + final groups = >{}; + final labels = {}; + for (final it in items) { + final slug = it.canonicalCategory.isNotEmpty + ? it.canonicalCategory + : (it.category.isNotEmpty ? it.category : 'other'); + groups.putIfAbsent(slug, () => []).add(it); + labels[slug] = it.canonicalCategoryLabel.isNotEmpty + ? it.canonicalCategoryLabel + : (it.category.isNotEmpty ? it.category : 'Other'); + } + final slugs = groups.keys.toList() + ..sort((a, b) { + final ia = _order.indexOf(a); + final ib = _order.indexOf(b); + // Unknown slugs sort after the known order, alphabetically. + if (ia == -1 && ib == -1) return a.compareTo(b); + if (ia == -1) return 1; + if (ib == -1) return -1; + return ia.compareTo(ib); + }); + + Widget grid(List gi) => GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisSpacing: ChainSpace.md, + crossAxisSpacing: ChainSpace.md, + mainAxisExtent: 168, + ), + itemCount: gi.length, + itemBuilder: (context, i) => _StoreCard( + item: gi[i], + locale: locale, + installedVersion: installedVersions[gi[i].name], + onTap: () => onTap(gi[i]), + onInstall: () => onInstall(gi[i]), + ), + ); + + // A single category (e.g. the store is already filtered to + // one) renders without a redundant header. + if (slugs.length <= 1) { + return grid(items); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final slug in slugs) ...[ + Padding( + padding: const EdgeInsets.only( + top: ChainSpace.lg, + bottom: ChainSpace.sm, + ), + child: Row( + children: [ + Text( + labels[slug] ?? slug, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(width: ChainSpace.sm), + Text( + '${groups[slug]!.length}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + grid(groups[slug]!), + ], + ], ); }, ); From 0fda2600ada9b7813f583aa0bc520d183891b551 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 9 Jul 2026 18:17:50 +0200 Subject: [PATCH 2/3] fix(store,l10n): localize canonical category labels + store segment The grouped store showed the hub's English category labels verbatim (Documents, Data & Formats) even in German. Map the canonical slug to DE/EN via l10n (storeCat*), and localize the Modules/Studio segment (storeSegment*). Falls back to the hub label for an unknown slug. Signed-off-by: flemming-it --- lib/l10n/app_de.arb | 11 +++++ lib/l10n/app_en.arb | 11 +++++ lib/l10n/app_localizations.dart | 68 +++++++++++++++++++++++++++++- lib/l10n/app_localizations_de.dart | 33 +++++++++++++++ lib/l10n/app_localizations_en.dart | 33 +++++++++++++++ lib/pages/store.dart | 42 +++++++++++++++--- 6 files changed, 191 insertions(+), 7 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 470ec18..6d4375a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -327,6 +327,17 @@ "storeCategoryChannel": "Kanäle", "storeCategoryWeb": "Web", "storeCategoryOrchestrator": "Orchestrierung", + "storeCatDocuments": "Dokumente", + "storeCatTextLanguage": "Text & Sprache", + "storeCatData": "Daten & Formate", + "storeCatAiLlm": "KI & LLM", + "storeCatWebApi": "Web & APIs", + "storeCatAnalysisDomain": "Analyse & Domäne", + "storeCatStudioThemes": "Studio & Themes", + "storeCatExamplesDev": "Beispiele & Dev", + "storeCatOther": "Sonstiges", + "storeSegmentModules": "Module", + "storeSegmentStudio": "Studio & Themes", "storeNResults": "{n} Treffer", "@storeNResults": { "placeholders": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a6572ee..dc8b8a7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -335,6 +335,17 @@ "storeCategoryChannel": "Channels", "storeCategoryWeb": "Web", "storeCategoryOrchestrator": "Orchestration", + "storeCatDocuments": "Documents", + "storeCatTextLanguage": "Text & Language", + "storeCatData": "Data & Formats", + "storeCatAiLlm": "AI & LLM", + "storeCatWebApi": "Web & APIs", + "storeCatAnalysisDomain": "Analysis & Domain", + "storeCatStudioThemes": "Studio & Themes", + "storeCatExamplesDev": "Examples & Dev", + "storeCatOther": "Other", + "storeSegmentModules": "Modules", + "storeSegmentStudio": "Studio & Themes", "storeNResults": "{n} result{n, plural, =1{} other{s}}", "@storeNResults": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bc599f3..aba6858 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -221,7 +221,7 @@ abstract class AppLocalizations { /// No description provided for @welcomeHeroSubtitle. /// /// In en, this message translates to: - /// **'Deterministic workflow engine for AI-assisted document processing in regulated environments.'** + /// **'Deterministic workflow engine for AI-assisted data processing in regulated environments.'** String get welcomeHeroSubtitle; /// No description provided for @welcomePillarHubTitle. @@ -1526,6 +1526,72 @@ abstract class AppLocalizations { /// **'Orchestration'** String get storeCategoryOrchestrator; + /// No description provided for @storeCatDocuments. + /// + /// In en, this message translates to: + /// **'Documents'** + String get storeCatDocuments; + + /// No description provided for @storeCatTextLanguage. + /// + /// In en, this message translates to: + /// **'Text & Language'** + String get storeCatTextLanguage; + + /// No description provided for @storeCatData. + /// + /// In en, this message translates to: + /// **'Data & Formats'** + String get storeCatData; + + /// No description provided for @storeCatAiLlm. + /// + /// In en, this message translates to: + /// **'AI & LLM'** + String get storeCatAiLlm; + + /// No description provided for @storeCatWebApi. + /// + /// In en, this message translates to: + /// **'Web & APIs'** + String get storeCatWebApi; + + /// No description provided for @storeCatAnalysisDomain. + /// + /// In en, this message translates to: + /// **'Analysis & Domain'** + String get storeCatAnalysisDomain; + + /// No description provided for @storeCatStudioThemes. + /// + /// In en, this message translates to: + /// **'Studio & Themes'** + String get storeCatStudioThemes; + + /// No description provided for @storeCatExamplesDev. + /// + /// In en, this message translates to: + /// **'Examples & Dev'** + String get storeCatExamplesDev; + + /// No description provided for @storeCatOther. + /// + /// In en, this message translates to: + /// **'Other'** + String get storeCatOther; + + /// No description provided for @storeSegmentModules. + /// + /// In en, this message translates to: + /// **'Modules'** + String get storeSegmentModules; + + /// No description provided for @storeSegmentStudio. + /// + /// In en, this message translates to: + /// **'Studio & Themes'** + String get storeSegmentStudio; + /// No description provided for @storeNResults. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5f8c754..4d07d61 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -820,6 +820,39 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storeCategoryOrchestrator => 'Orchestrierung'; + @override + String get storeCatDocuments => 'Dokumente'; + + @override + String get storeCatTextLanguage => 'Text & Sprache'; + + @override + String get storeCatData => 'Daten & Formate'; + + @override + String get storeCatAiLlm => 'KI & LLM'; + + @override + String get storeCatWebApi => 'Web & APIs'; + + @override + String get storeCatAnalysisDomain => 'Analyse & Domäne'; + + @override + String get storeCatStudioThemes => 'Studio & Themes'; + + @override + String get storeCatExamplesDev => 'Beispiele & Dev'; + + @override + String get storeCatOther => 'Sonstiges'; + + @override + String get storeSegmentModules => 'Module'; + + @override + String get storeSegmentStudio => 'Studio & Themes'; + @override String storeNResults(int n) { return '$n Treffer'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index cee3def..a69c45e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -833,6 +833,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storeCategoryOrchestrator => 'Orchestration'; + @override + String get storeCatDocuments => 'Documents'; + + @override + String get storeCatTextLanguage => 'Text & Language'; + + @override + String get storeCatData => 'Data & Formats'; + + @override + String get storeCatAiLlm => 'AI & LLM'; + + @override + String get storeCatWebApi => 'Web & APIs'; + + @override + String get storeCatAnalysisDomain => 'Analysis & Domain'; + + @override + String get storeCatStudioThemes => 'Studio & Themes'; + + @override + String get storeCatExamplesDev => 'Examples & Dev'; + + @override + String get storeCatOther => 'Other'; + + @override + String get storeSegmentModules => 'Modules'; + + @override + String get storeSegmentStudio => 'Studio & Themes'; + @override String storeNResults(int n) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 4d38484..f1e0e03 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -367,16 +367,18 @@ class _StorePageState extends State { padding: const EdgeInsets.only(bottom: ChainSpace.md), child: SegmentedButton( - segments: const [ + segments: [ ButtonSegment( value: false, - label: Text('Module'), - icon: Icon(Icons.extension_outlined, size: 16), + label: Text(l.storeSegmentModules), + icon: const Icon(Icons.extension_outlined, + size: 16), ), ButtonSegment( value: true, - label: Text('Studio & Themes'), - icon: Icon(Icons.palette_outlined, size: 16), + label: Text(l.storeSegmentStudio), + icon: + const Icon(Icons.palette_outlined, size: 16), ), ], selected: {_showStudio}, @@ -799,6 +801,34 @@ String _sourceForItem(StoreItem i) { /// data model — only the display side gets localised. Unknown /// ids fall through unchanged so newly-added categories don't /// vanish until we update this map. +/// Localized label for a canonical category slug (from the hub). Falls +/// back to the hub's English label / raw category for an unknown slug. +String _canonicalCatLabel(BuildContext ctx, String slug, String fallback) { + final l = AppLocalizations.of(ctx)!; + switch (slug) { + case 'documents': + return l.storeCatDocuments; + case 'text-language': + return l.storeCatTextLanguage; + case 'data': + return l.storeCatData; + case 'ai-llm': + return l.storeCatAiLlm; + case 'web-api': + return l.storeCatWebApi; + case 'analysis-domain': + return l.storeCatAnalysisDomain; + case 'studio-themes': + return l.storeCatStudioThemes; + case 'examples-dev': + return l.storeCatExamplesDev; + case 'other': + return l.storeCatOther; + default: + return fallback.isNotEmpty ? fallback : slug; + } +} + String _categoryDisplayName(BuildContext ctx, String wire) { final l = AppLocalizations.of(ctx)!; switch (wire) { @@ -1651,7 +1681,7 @@ class _StoreGrid extends StatelessWidget { child: Row( children: [ Text( - labels[slug] ?? slug, + _canonicalCatLabel(context, slug, labels[slug] ?? ''), style: Theme.of(context) .textTheme .titleSmall From bb606a8b23be6b379c0dd9bcd361c7568b56e4b6 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 11 Jul 2026 02:38:24 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(approvals,l10n,theme):=20usertest=20fin?= =?UTF-8?q?dings=20=E2=80=94=20localized=20prompts,=20no-data=20confirm,?= =?UTF-8?q?=20.chain=20log=20path,=20honest=20wording,=20AA=20contrast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - approvals: empty/legacy hub prompts render the localized fallback; approving without show: data asks for conscious confirmation first - chain_log: write to ~/.chain/logs/studio-errors.log (was .fai), one-time best-effort migration of the legacy file + rotation sibling - l10n: 'manipulationssicher' -> 'manipulationserkennend', neutral WORM-1 blurb, doctor pill 'Integritätskette v1', federation hint says the CA authenticates the first connect (DE+EN) - theme: muted text token now >=4.5:1 on canvas, cards and elevated dark surfaces (was 3.7:1 on cards) Signed-off-by: flemming-it --- CHANGELOG.md | 25 ++++++++++++++ lib/data/chain_log.dart | 26 +++++++++++++- lib/l10n/app_de.arb | 13 ++++--- lib/l10n/app_en.arb | 9 +++-- lib/l10n/app_localizations.dart | 24 +++++++++++-- lib/l10n/app_localizations_de.dart | 20 ++++++++--- lib/l10n/app_localizations_en.dart | 16 +++++++-- lib/pages/approvals.dart | 54 +++++++++++++++++++++++++++--- lib/pages/store.dart | 2 +- lib/theme/tokens.dart | 4 ++- 10 files changed, 167 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0148005..9cd4326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep. ## Unreleased +### Fixed (usertest 2026-07-10 findings) + +- **Approval prompts localize.** An approval whose flow step gave no + `prompt:` (and legacy rows carrying the hub's old baked-in English + sentence) now renders the localized fallback "Freigabe für diesen + Schritt erforderlich" / "Approval required for this step" — no more + English inside the German approvals UI. +- **Approving without review data asks first.** When the approval step + attached no `show:` payload, "Freigeben" opens a calm confirmation + ("Ohne Prüfdaten freigeben?") explaining that the flow deliberately + attached no data, with an explicit "Trotzdem freigeben". +- **Studio error log moved to `~/.chain/logs/`.** Writes went to the + pre-rename `~/.fai/logs/studio-errors.log` while the Doctor page and + `chain doctor` read `~/.chain/…`. Studio now writes to `.chain` and + migrates the old file (+ rotation sibling) over once. +- **Honest audit wording (legal review).** DE strings no longer claim + "manipulationssicher" — the audit log is *manipulationserkennend* + (tamper-evident); "warum WORM-1 für KRITIS reicht" became a neutral + what-it-does-and-does-not sentence (EN too); the Doctor chain pill + says "Integritätskette v1" instead of "WORM-1"; the federation + enrollment hint says the CA *authenticates* the first connect. +- **WCAG-AA secondary text on dark.** De-emphasised text was 3.7:1 on + cards; the muted token is now ≥ 4.5:1 against canvas, cards and + elevated surfaces. + ### Added - **Live audit feed.** The Audit page subscribes to `streamEvents` and diff --git a/lib/data/chain_log.dart b/lib/data/chain_log.dart index 440f23a..d3411bf 100644 --- a/lib/data/chain_log.dart +++ b/lib/data/chain_log.dart @@ -51,7 +51,31 @@ class ChainLog { Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; - return p.join(home, '.fai', 'logs', 'studio-errors.log'); + final path = p.join(home, '.chain', 'logs', 'studio-errors.log'); + _migrateLegacyLog(home, path); + return path; + } + + // Pre-rename installs wrote to `~/.fai/logs/`. Move that file (and + // its rotation sibling) over once so the error trail survives the + // rename; never overwrite an existing new-path file. Best-effort + // and cheap enough to run per access (two stat calls after the + // first migration). + static void _migrateLegacyLog(String home, String newPath) { + try { + for (final suffix in const ['', '.1']) { + final legacy = File( + p.join(home, '.fai', 'logs', 'studio-errors.log$suffix'), + ); + final target = File('$newPath$suffix'); + if (legacy.existsSync() && !target.existsSync()) { + target.parent.createSync(recursive: true); + legacy.renameSync(target.path); + } + } + } catch (_) { + // Best-effort: a failed migration must not break logging. + } } /// Absolute path of the studio-errors log. Public so the diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6d4375a..d62b38f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -30,7 +30,7 @@ "welcomeTrustHeader": "TRUST-POSTURE", "welcomeTrustSandboxTitle": "Sandbox von Anfang an", "welcomeTrustSandboxBody": "Jedes Modul bringt eine explizite Berechtigungsliste mit — Netzwerk-Endpunkte, Dateien, Umgebungsvariablen. Der Hub setzt sie durch; ohne Operator-Freigabe verlässt nichts die Sandbox.", - "welcomeTrustAuditTitle": "Manipulationssicheres Audit-Log", + "welcomeTrustAuditTitle": "Manipulationserkennendes Audit-Log", "welcomeTrustAuditBody": "Flow-Läufe, Installationen, Deinstallationen, Freigabe-Entscheidungen — alles wird in ein hash-verkettetes Audit-Log geschrieben. Die Diagnose-Seite verifiziert die Kette bei jedem Laden komplett.", "welcomeTrustAirgapTitle": "Air-Gap-tauglich", "welcomeTrustAirgapBody": "Der gesamte Hub steckt in einem einzigen Binary für Linux, macOS und Windows. Sobald ein Modul installiert ist, läuft der Flow, der es nutzt, ohne weiteren Netzwerkzugriff — ideal für regulierte Umgebungen.", @@ -40,8 +40,8 @@ "welcomeDocArchitectureBlurb": "Hub, Modul, Flow — und wie die drei zusammenpassen.", "welcomeDocSecurityTitle": "Sandbox-Modell", "welcomeDocSecurityBlurb": "Was ein Modul deklariert, was der Operator deckelt, was der Hub durchsetzt.", - "welcomeDocAuditTitle": "Manipulationssicheres Audit-Log", - "welcomeDocAuditBlurb": "Wie die Hash-Kette funktioniert und warum WORM-1 für KRITIS reicht.", + "welcomeDocAuditTitle": "Manipulationserkennendes Audit-Log", + "welcomeDocAuditBlurb": "Wie die Hash-Kette nachträgliche Änderungen erkennbar macht — und was die Integritätsstufe WORM-1 leistet (und was nicht).", "welcomeDocFlowsTitle": "Flow-Komposition", "welcomeDocFlowsBlurb": "YAML-Grundlagen, Templating-Referenz, das Extract→Summarize-Beispiel.", "welcomeDocApprovalsTitle": "Freigaben", @@ -1046,6 +1046,9 @@ "approvalsPillExpired": "abgelaufen", "approvalsPayloadPreview": "ZU PRÜFENDE DATEN", "approvalsNoPayload": "Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setze es, um die Daten hinter dieser Entscheidung sichtbar zu machen.", + "approvalsNoDataConfirmTitle": "Ohne Prüfdaten freigeben?", + "approvalsNoDataConfirmBody": "Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Du kannst trotzdem freigeben — entscheidest dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.", + "approvalsNoDataConfirmAction": "Trotzdem freigeben", "approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich", "approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}", "approvalsApproveButton": "Freigeben", @@ -1193,7 +1196,7 @@ } } }, - "doctorChainPillOk": "WORM-1", + "doctorChainPillOk": "Integritätskette v1", "doctorChainPillTamper": "MANIPULIERT", "doctorVerifyNow": "Jetzt prüfen", "doctorRestart": "Neustart", @@ -1661,5 +1664,5 @@ "federationTokenLabel": "Bootstrap-Token (einmalig)", "federationConfigLabel": "Satelliten-Konfiguration (in den Satelliten einfügen)", "federationCopied": "In die Zwischenablage kopiert", - "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher." + "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dc8b8a7..e781046 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -49,7 +49,7 @@ "welcomeDocSecurityTitle": "Sandbox model", "welcomeDocSecurityBlurb": "What a module declares, what the operator caps, what the hub enforces.", "welcomeDocAuditTitle": "Tamper-evident audit log", - "welcomeDocAuditBlurb": "How the hash chain works and why WORM-1 is enough for KRITIS.", + "welcomeDocAuditBlurb": "How the hash chain makes later edits detectable — and what integrity level WORM-1 does (and does not) provide.", "welcomeDocFlowsTitle": "Flow composition", "welcomeDocFlowsBlurb": "YAML basics, templating reference, the extract→summarize example.", "welcomeDocApprovalsTitle": "Approvals", @@ -1064,6 +1064,9 @@ "approvalsPillExpired": "expired", "approvalsPayloadPreview": "DATA TO REVIEW", "approvalsNoPayload": "No data was attached for review. The flow's approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.", + "approvalsNoDataConfirmTitle": "Approve without review data?", + "approvalsNoDataConfirmBody": "This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.", + "approvalsNoDataConfirmAction": "Approve anyway", "approvalsRequestFallback": "Approval required for this step", "approvalsFlowStepMeta": "Flow: {flow} · Step: {step}", "@approvalsFlowStepMeta": { @@ -1217,7 +1220,7 @@ } } }, - "doctorChainPillOk": "WORM-1", + "doctorChainPillOk": "Integrity chain v1", "doctorChainPillTamper": "TAMPER", "doctorVerifyNow": "Verify now", "doctorRestart": "Restart", @@ -1700,5 +1703,5 @@ "federationTokenLabel": "Bootstrap token (single use)", "federationConfigLabel": "Satellite config (paste into the satellite)", "federationCopied": "Copied to clipboard", - "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite's first connect tamper-proof." + "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index aba6858..74e3d5c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -347,7 +347,7 @@ abstract class AppLocalizations { /// No description provided for @welcomeDocAuditBlurb. /// /// In en, this message translates to: - /// **'How the hash chain works and why WORM-1 is enough for KRITIS.'** + /// **'How the hash chain makes later edits detectable — and what integrity level WORM-1 does (and does not) provide.'** String get welcomeDocAuditBlurb; /// No description provided for @welcomeDocFlowsTitle. @@ -3254,6 +3254,24 @@ abstract class AppLocalizations { /// **'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.'** String get approvalsNoPayload; + /// No description provided for @approvalsNoDataConfirmTitle. + /// + /// In en, this message translates to: + /// **'Approve without review data?'** + String get approvalsNoDataConfirmTitle; + + /// No description provided for @approvalsNoDataConfirmBody. + /// + /// In en, this message translates to: + /// **'This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.'** + String get approvalsNoDataConfirmBody; + + /// No description provided for @approvalsNoDataConfirmAction. + /// + /// In en, this message translates to: + /// **'Approve anyway'** + String get approvalsNoDataConfirmAction; + /// No description provided for @approvalsRequestFallback. /// /// In en, this message translates to: @@ -3527,7 +3545,7 @@ abstract class AppLocalizations { /// No description provided for @doctorChainPillOk. /// /// In en, this message translates to: - /// **'WORM-1'** + /// **'Integrity chain v1'** String get doctorChainPillOk; /// No description provided for @doctorChainPillTamper. @@ -4870,7 +4888,7 @@ abstract class AppLocalizations { /// No description provided for @federationEnrollmentHint. /// /// In en, this message translates to: - /// **'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'** + /// **'Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite\'s first connect.'** String get federationEnrollmentHint; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4d07d61..d7ddf32 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -110,7 +110,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Jedes Modul bringt eine explizite Berechtigungsliste mit — Netzwerk-Endpunkte, Dateien, Umgebungsvariablen. Der Hub setzt sie durch; ohne Operator-Freigabe verlässt nichts die Sandbox.'; @override - String get welcomeTrustAuditTitle => 'Manipulationssicheres Audit-Log'; + String get welcomeTrustAuditTitle => 'Manipulationserkennendes Audit-Log'; @override String get welcomeTrustAuditBody => @@ -145,11 +145,11 @@ class AppLocalizationsDe extends AppLocalizations { 'Was ein Modul deklariert, was der Operator deckelt, was der Hub durchsetzt.'; @override - String get welcomeDocAuditTitle => 'Manipulationssicheres Audit-Log'; + String get welcomeDocAuditTitle => 'Manipulationserkennendes Audit-Log'; @override String get welcomeDocAuditBlurb => - 'Wie die Hash-Kette funktioniert und warum WORM-1 für KRITIS reicht.'; + 'Wie die Hash-Kette nachträgliche Änderungen erkennbar macht — und was die Integritätsstufe WORM-1 leistet (und was nicht).'; @override String get welcomeDocFlowsTitle => 'Flow-Komposition'; @@ -1871,6 +1871,16 @@ class AppLocalizationsDe extends AppLocalizations { String get approvalsNoPayload => 'Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setze es, um die Daten hinter dieser Entscheidung sichtbar zu machen.'; + @override + String get approvalsNoDataConfirmTitle => 'Ohne Prüfdaten freigeben?'; + + @override + String get approvalsNoDataConfirmBody => + 'Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Du kannst trotzdem freigeben — entscheidest dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.'; + + @override + String get approvalsNoDataConfirmAction => 'Trotzdem freigeben'; + @override String get approvalsRequestFallback => 'Freigabe für diesen Schritt erforderlich'; @@ -2043,7 +2053,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get doctorChainPillOk => 'WORM-1'; + String get doctorChainPillOk => 'Integritätskette v1'; @override String get doctorChainPillTamper => 'MANIPULIERT'; @@ -2860,5 +2870,5 @@ class AppLocalizationsDe extends AppLocalizations { @override String get federationEnrollmentHint => - 'Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher.'; + 'Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a69c45e..60802b4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -150,7 +150,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get welcomeDocAuditBlurb => - 'How the hash chain works and why WORM-1 is enough for KRITIS.'; + 'How the hash chain makes later edits detectable — and what integrity level WORM-1 does (and does not) provide.'; @override String get welcomeDocFlowsTitle => 'Flow composition'; @@ -1882,6 +1882,16 @@ class AppLocalizationsEn extends AppLocalizations { String get approvalsNoPayload => 'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.'; + @override + String get approvalsNoDataConfirmTitle => 'Approve without review data?'; + + @override + String get approvalsNoDataConfirmBody => + 'This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.'; + + @override + String get approvalsNoDataConfirmAction => 'Approve anyway'; + @override String get approvalsRequestFallback => 'Approval required for this step'; @@ -2052,7 +2062,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get doctorChainPillOk => 'WORM-1'; + String get doctorChainPillOk => 'Integrity chain v1'; @override String get doctorChainPillTamper => 'TAMPER'; @@ -2863,5 +2873,5 @@ class AppLocalizationsEn extends AppLocalizations { @override String get federationEnrollmentHint => - 'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'; + 'Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite\'s first connect.'; } diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index e13e168..9248df8 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -10,6 +10,24 @@ import '../theme/tokens.dart'; import '../widgets/widgets.dart'; import 'welcome.dart' show showFaiDoc; +/// The fixed English sentence pre-0.21 hubs baked into stored +/// approvals when the flow gave no `prompt:`. Newer hubs store the +/// empty prompt verbatim. +const _legacyHubPromptDefault = + 'Please review and approve this step before continuing.'; + +/// Reviewer-facing prompt with fallbacks: an empty prompt (the flow +/// gave none) renders the localized default, and the legacy English +/// default from old hub rows is mapped onto the same localized +/// default so it stops showing English inside a German UI. +String displayApprovalPrompt(AppLocalizations l, String prompt) { + final trimmed = prompt.trim(); + if (trimmed.isEmpty || trimmed == _legacyHubPromptDefault) { + return l.approvalsRequestFallback; + } + return prompt; +} + class ApprovalsPage extends StatefulWidget { const ApprovalsPage({super.key}); @@ -68,6 +86,29 @@ class _ApprovalsPageState extends State Future _approve(ApprovalRecord a) async { final l = AppLocalizations.of(context)!; + // No `show:` data attached → never approve on a reflex. Calmly + // explain and ask for a conscious confirmation first. + if (a.payloadPreview == null) { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l.approvalsNoDataConfirmTitle), + content: Text(l.approvalsNoDataConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l.buttonCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l.approvalsNoDataConfirmAction), + ), + ], + ), + ); + if (confirmed != true) return; + } + if (!mounted) return; try { await HubService.instance.approve(a.id, _reviewer); _toast(l.approvalsApprovedToast(a.flowName, a.stepId)); @@ -77,6 +118,7 @@ class _ApprovalsPageState extends State } } + Future _reject(ApprovalRecord a) async { final l = AppLocalizations.of(context)!; final reason = await _promptReason(context); @@ -558,9 +600,7 @@ class _ApprovalCard extends StatelessWidget { // step left the prompt empty, so the card is never reduced to // a cryptic flow id. Text( - approval.prompt.trim().isEmpty - ? l.approvalsRequestFallback - : approval.prompt, + displayApprovalPrompt(l, approval.prompt), style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -850,7 +890,13 @@ class _HistoryDialog extends StatelessWidget { ), ), const SizedBox(height: 4), - SelectableText(record.prompt, style: theme.textTheme.bodyMedium), + SelectableText( + displayApprovalPrompt( + AppLocalizations.of(context)!, + record.prompt, + ), + style: theme.textTheme.bodyMedium, + ), if (record.payloadPreview != null) ...[ const SizedBox(height: ChainSpace.md), Text( diff --git a/lib/pages/store.dart b/lib/pages/store.dart index f1e0e03..19b7e5c 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -3252,7 +3252,7 @@ const List _kFallbackTodayStories = [ badgeEn: 'AUDIT', badgeDe: 'AUDIT', titleEn: 'Tamper-evident hash chain — built in', - titleDe: 'Manipulationssicher per Hash-Kette — eingebaut', + titleDe: 'Manipulation erkennbar per Hash-Kette — eingebaut', bodyEn: 'Every flow run, every install, every approval lands in ~/.chain/audit/ as a hash-chained event log. Any later edit invalidates the chain. CRA-ready out of the box — no compliance product to buy on top.', bodyDe: diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart index fdd010d..135d714 100644 --- a/lib/theme/tokens.dart +++ b/lib/theme/tokens.dart @@ -27,7 +27,9 @@ class ChainColors { static const surface = Color(0xFF18181B); // cards static const surfaceHigh = Color(0xFF27272A); // elevated static const border = Color(0xFF3F3F46); // 1px outlines - static const muted = Color(0xFF71717A); // de-emphasised text + // De-emphasised text. WCAG AA (≥4.5:1) against canvas, cards AND + // elevated surfaces — 0xFF71717A only reached 3.7:1 on cards. + static const muted = Color(0xFF8E8E97); static const text = Color(0xFFE4E4E7); // body static const textStrong = Color(0xFFFAFAFA); // headings