feat(studio): de-jargonize, reveal-in-OS, copyable errors, today carousel, AppBar alignment, F∆I window title (v0.34.0)

Sweeping pass against six user reports collected this session.

1. "Capabilities ist nicht deutsch, Chain auch nicht."
   The DE locale still leaked English vocabulary. Replaced
   "Capabilities" → "Fähigkeiten" and "Chain" / "Hash-Chain"
   → "Kette" / "Hash-Kette" everywhere — store search hint,
   recommended-source body, federated toast, doctor summary
   chain row, modules panel summary, MCP / n8n hints, the
   approvals history blurb. Wire-level identifiers
   (`chain.reset`) stay as code.

2. "Bei Fehlern unten muss man die auch ins clipboard
   kopieren können." New `FaiErrorBox` widget: selectable
   monospace block with a small copy-to-clipboard icon
   button that flips to a checkmark for two seconds after
   click. Applied to the Doctor update banner output and
   the Settings channel toast — the two places long
   stderr / stdout lands.

3. "Öffnen bei Log kann es nicht öffnen. Audit-DB auch
   nicht. PID auch nicht."
   Cause: `SystemActions.openInOs` shells out to `open` /
   `xdg-open` on file paths the OS has no default handler
   for (SQLite DB, PID file, log without an .ext that
   binds). New `revealInOs` uses `open -R` on macOS,
   `explorer /select,` on Windows, and the parent
   directory via `xdg-open` on Linux. Doctor's path rows
   carry an `isDirectory` flag that routes through the new
   `openOrReveal` so files reveal in Finder / Explorer
   instead of failing silently.

4. "Oben im Store könnte man diesen Redaktionshinweis auch
   so bauen, dass man mit pfeil nach rechts links auch
   weitere anzeigen kann."
   The Today-Hero became a carousel. Curated fallback
   list grew from one entry to four (public sources, the
   sandbox-by-default permission story, the hash-chained
   audit story, the air-gap-ready single-binary pitch).
   Hero gets prev / next chevrons plus a dot indicator
   when the current snapshot has more than one slide.
   Operator-accepted stories stay single — the carousel
   collapses when there's only one to show.

5. "Ich fände es schöner wenn rechts und links im Store
   die Abstände konsistent sind, das Reload-Symbol rechts
   ist zu weit rechts und Store links auch nicht bündig."
   AppBar now has `titleSpacing: FaiSpace.xl` so the
   title's left edge sits flush with the body's left
   padding (24 dp), and the trailing `SizedBox` after the
   reload icon shrunk so the icon's outer edge meets the
   right edge of the rightmost grid card.

6. "Oben der Titel zeigt fai_studio an, das sollte F∆I
   Studio sein." The OS window title was the
   pubspec-derived "fai_studio". Macos/Linux/Windows
   runners now hard-code "F∆I Studio" (with the U+2206
   triangle escape so the C++ source stays ASCII). macOS
   bundle name and display name lifted out of the
   PRODUCT_NAME variable for the same reason.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 14:48:33 +02:00
parent 22a851b7e2
commit 3b5fb027c3
17 changed files with 454 additions and 119 deletions

View file

@ -334,13 +334,17 @@ class _DaemonPathsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final entries = <(String, String, IconData)>[
(l.doctorPathLog, paths.logPath, Icons.description_outlined),
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined),
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined),
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined),
(l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined),
(l.doctorPathPid, paths.pidPath, Icons.fingerprint),
// Per-row layout: (label, path, icon, isDirectory). The
// isDirectory bit drives `openOrReveal` so binary files
// (the SQLite audit DB, the PID file) reveal in Finder /
// Explorer instead of failing the OS "open" handler.
final entries = <(String, String, IconData, bool)>[
(l.doctorPathLog, paths.logPath, Icons.description_outlined, false),
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined, false),
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined, false),
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined, true),
(l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined, true),
(l.doctorPathPid, paths.pidPath, Icons.fingerprint, false),
].where((e) => e.$2.isNotEmpty).toList();
if (entries.isEmpty) {
@ -356,7 +360,13 @@ class _DaemonPathsPanel extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final e in entries) _PathRow(label: e.$1, path: e.$2, icon: e.$3),
for (final e in entries)
_PathRow(
label: e.$1,
path: e.$2,
icon: e.$3,
isDirectory: e.$4,
),
],
),
);
@ -367,8 +377,14 @@ class _PathRow extends StatelessWidget {
final String label;
final String path;
final IconData icon;
final bool isDirectory;
const _PathRow({required this.label, required this.path, required this.icon});
const _PathRow({
required this.label,
required this.path,
required this.icon,
required this.isDirectory,
});
@override
Widget build(BuildContext context) {
@ -400,7 +416,10 @@ class _PathRow extends StatelessWidget {
const SizedBox(width: FaiSpace.sm),
OutlinedButton.icon(
onPressed: () async {
final r = await SystemActions.openInOs(path);
final r = await SystemActions.openOrReveal(
path,
isDirectory: isDirectory,
);
if (!context.mounted) return;
if (!r.ok) {
final l = AppLocalizations.of(context)!;
@ -409,7 +428,10 @@ class _PathRow extends StatelessWidget {
);
}
},
icon: const Icon(Icons.open_in_new, size: 14),
icon: Icon(
isDirectory ? Icons.folder_open : Icons.open_in_new,
size: 14,
),
label: Text(AppLocalizations.of(context)!.buttonOpen),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
@ -895,22 +917,7 @@ class _UpdateBannerState extends State<_UpdateBanner> {
),
if (_applyOutput != null) ...[
const SizedBox(height: FaiSpace.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
_applyOutput!,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
FaiErrorBox(text: _applyOutput!, maxHeight: 240),
],
],
),

View file

@ -42,11 +42,24 @@ class _StorePageState extends State<StorePage> {
/// session. Not persisted the next Studio launch shows it
/// again so a release-bumped story has a chance to be seen.
bool _todayDismissed = false;
/// Loaded once at init from `~/.fai/today/active.yaml`; falls
/// back to the compiled-in story when the pipeline hasn't
/// produced an accepted candidate yet.
late final TodayStoryData _todayStory =
TodayStoryLoader.loadOrFallback(_kFallbackTodayStory);
/// The carousel of stories rendered in the hero. When an
/// operator-accepted story exists at `~/.fai/today/active.yaml`,
/// it is the only entry (carousel collapses to a single
/// slide). Otherwise the curated fallback list rotates.
late final List<TodayStoryData> _todayStories = (() {
final loaded =
TodayStoryLoader.loadOrFallback(_kFallbackTodayStories.first);
// When the loader fell back to the const, it returned the
// first entry of our list surface the whole list so the
// operator can browse. Otherwise the loader produced a real
// accepted story and we honour it as the only item.
if (identical(loaded, _kFallbackTodayStories.first)) {
return _kFallbackTodayStories;
}
return <TodayStoryData>[loaded];
})();
/// Carousel index. Wraps around modulo `_todayStories.length`.
int _todayIndex = 0;
late Future<List<StoreItem>> _future;
// AI search state
@ -151,11 +164,13 @@ class _StorePageState extends State<StorePage> {
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
// titleSpacing matches the body's horizontal padding
// (FaiSpace.xl == 24) so the title's left edge sits
// flush with the cards below. Default 16 left a 8-px
// gap that read as a misaligned heading.
titleSpacing: FaiSpace.xl,
title: Text(l.searchGroupStore),
actions: [
// Category dropdown and Filter button live in the
// toolbar so the body stays uncluttered. Both refresh
// off the current store snapshot via FutureBuilder.
FutureBuilder<List<StoreItem>>(
future: _future,
builder: (ctx, snap) {
@ -186,7 +201,11 @@ class _StorePageState extends State<StorePage> {
tooltip: l.storeReloadTooltip,
onPressed: _runSearch,
),
const SizedBox(width: FaiSpace.sm),
// Trailing pad pulls the IconButton's outer edge in
// to match the body's right-side padding. The icon
// glyph itself ends up flush with the rightmost grid
// card, not floating in space.
const SizedBox(width: FaiSpace.md),
],
),
body: Column(
@ -287,11 +306,31 @@ class _StorePageState extends State<StorePage> {
],
if (showToday) ...[
_StoreTodayHero(
story: _todayStory,
story:
_todayStories[_todayIndex % _todayStories.length],
locale: _locale,
currentIndex: _todayIndex,
totalCount: _todayStories.length,
onPrev: _todayStories.length > 1
? () => setState(() {
_todayIndex = (_todayIndex -
1 +
_todayStories.length) %
_todayStories.length;
})
: null,
onNext: _todayStories.length > 1
? () => setState(() {
_todayIndex = (_todayIndex + 1) %
_todayStories.length;
})
: null,
onDismiss: () =>
setState(() => _todayDismissed = true),
onCta: _todayStory.cta == TodayCta.openSettings
onCta: _todayStories[
_todayIndex % _todayStories.length]
.cta ==
TodayCta.openSettings
? () => FaiSettingsDialog.show(context)
: null,
recommendedSources: embedRecommended
@ -2659,24 +2698,70 @@ class _ScreenshotPlaceholder extends StatelessWidget {
}
}
/// Compiled-in fallback story shown when
/// Compiled-in fallback stories shown when
/// `~/.fai/today/active.yaml` is absent or fails schema
/// validation. The pipeline operator can override at any time
/// without recompiling Studio see docs/today-pipeline.md.
const TodayStoryData _kFallbackTodayStory = TodayStoryData(
badgeEn: 'PUBLIC SOURCES',
badgeDe: 'ÖFFENTLICHE QUELLEN',
titleEn: 'Fill your store in two clicks',
titleDe: 'Den Store in zwei Klicks auffüllen',
bodyEn:
'Right now the store shows only the modules that ship with the hub. Pick a public source from the buttons below and the store fills with whatever tools that server offers — straight over HTTPS, no install steps, nothing to configure.',
bodyDe:
'Im Moment zeigt der Store nur die Module, die mit dem Hub mitkommen. Wähle unten eine öffentliche Quelle aus, und der Store füllt sich mit allem, was dieser Server an Tools anbietet — direkt über HTTPS, ohne Installation, ohne weitere Konfiguration.',
ctaLabelEn: 'Manage sources',
ctaLabelDe: 'Quellen verwalten',
icon: Icons.hub_outlined,
cta: TodayCta.openSettings,
);
/// validation. Operator can override at any time without
/// recompiling Studio see docs/today-pipeline.md. When the
/// operator accepts a story, that single story becomes the
/// active item and the carousel collapses to a single slide.
const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
TodayStoryData(
badgeEn: 'PUBLIC SOURCES',
badgeDe: 'ÖFFENTLICHE QUELLEN',
titleEn: 'Fill your store in two clicks',
titleDe: 'Den Store in zwei Klicks auffüllen',
bodyEn:
'Right now the store shows only the modules that ship with the hub. Pick a public source from the buttons below and the store fills with whatever tools that server offers — straight over HTTPS, no install steps, nothing to configure.',
bodyDe:
'Im Moment zeigt der Store nur die Module, die mit dem Hub mitkommen. Wähle unten eine öffentliche Quelle aus, und der Store füllt sich mit allem, was dieser Server an Tools anbietet — direkt über HTTPS, ohne Installation, ohne weitere Konfiguration.',
ctaLabelEn: 'Manage sources',
ctaLabelDe: 'Quellen verwalten',
icon: Icons.hub_outlined,
cta: TodayCta.openSettings,
),
TodayStoryData(
badgeEn: 'SANDBOX BY DEFAULT',
badgeDe: 'SANDBOX VON ANFANG AN',
titleEn: 'Every module declares what it can touch',
titleDe: 'Jedes Modul deklariert, worauf es zugreifen darf',
bodyEn:
'F∆I modules ship with an explicit permission list — network endpoints, files, environment variables. The hub enforces it; nothing reaches outside the sandbox without the operator approving it. Click any installed module to see exactly which permissions it asked for.',
bodyDe:
'F∆I-Module bringen eine explizite Berechtigungsliste mit — Netzwerk-Endpunkte, Dateien, Umgebungsvariablen. Der Hub setzt sie durch; ohne Operator-Freigabe verlässt nichts die Sandbox. Klicke ein installiertes Modul, um seine genauen Berechtigungen zu sehen.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.shield_outlined,
cta: TodayCta.none,
),
TodayStoryData(
badgeEn: 'TAMPER-EVIDENT AUDIT',
badgeDe: 'MANIPULATIONSSICHERES AUDIT',
titleEn: 'Every event leaves a hash-chained trace',
titleDe: 'Jedes Ereignis hinterlässt eine hash-verkettete Spur',
bodyEn:
'Flow runs, install / uninstall actions, approval decisions — all of them write into a hash-chained audit log. The Doctor page verifies the chain end-to-end on every load; if a single byte gets touched, you see it. Audit-grade by construction, not by policy.',
bodyDe:
'Flow-Läufe, Installationen und Deinstallationen, Freigabe-Entscheidungen — alles wird in ein hash-verkettetes Audit-Log geschrieben. Die Diagnose-Seite verifiziert die Kette bei jedem Laden komplett; ein verändertes Byte ist sofort sichtbar. Audit-tauglich qua Konstruktion, nicht qua Policy.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.shield_outlined,
cta: TodayCta.none,
),
TodayStoryData(
badgeEn: 'AIR-GAP READY',
badgeDe: 'AIR-GAP-TAUGLICH',
titleEn: 'One binary, no Docker, no external services',
titleDe: 'Ein Binary, kein Docker, keine externen Dienste',
bodyEn:
'The whole hub fits in a single binary that runs on Linux, macOS and Windows. SQLite for state, no message broker, no background services to babysit. Once a module is installed, the flow it powers runs without further network access — perfect for regulated environments where outbound calls have to be justified.',
bodyDe:
'Der gesamte Hub steckt in einem einzigen Binary für Linux, macOS und Windows. SQLite für den State, kein Message-Broker, keine Hintergrunddienste, die gepflegt werden müssen. Sobald ein Modul installiert ist, läuft der Flow, der es nutzt, ohne weiteren Netzwerkzugriff — ideal für regulierte Umgebungen, in denen jeder ausgehende Call begründet werden muss.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.rocket_launch,
cta: TodayCta.none,
),
];
/// Editorial hero strip the first surface a browsing operator
/// sees. Plays the role of Apple's Today tab: one curated
@ -2686,6 +2771,15 @@ const TodayStoryData _kFallbackTodayStory = TodayStoryData(
class _StoreTodayHero extends StatelessWidget {
final TodayStoryData story;
final String locale;
/// Carousel position (0-based) and total slide count. Drives
/// the dot indicator and the visibility of the prev/next
/// arrows. When `totalCount == 1` the chrome collapses to a
/// single-card layout operator-accepted stories never wear
/// carousel dressing.
final int currentIndex;
final int totalCount;
final VoidCallback? onPrev;
final VoidCallback? onNext;
final VoidCallback onDismiss;
/// CTA action null hides the button. Wired by the parent
/// because navigation targets live in the store-page state,
@ -2703,6 +2797,10 @@ class _StoreTodayHero extends StatelessWidget {
const _StoreTodayHero({
required this.story,
required this.locale,
required this.currentIndex,
required this.totalCount,
required this.onPrev,
required this.onNext,
required this.onDismiss,
required this.onCta,
this.recommendedSources = const <_RecommendedSource>[],
@ -2848,11 +2946,62 @@ class _StoreTodayHero extends StatelessWidget {
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 16),
visualDensity: VisualDensity.compact,
tooltip: l.storeTodayDismissTooltip,
onPressed: onDismiss,
// Right-edge column: dismiss + (when more than one
// slide exists) a carousel control row. Stacking them
// vertically keeps the hero's main row tidy whatever
// the slide count.
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
visualDensity: VisualDensity.compact,
tooltip: l.storeTodayDismissTooltip,
onPressed: onDismiss,
),
if (totalCount > 1) ...[
const SizedBox(height: FaiSpace.sm),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onPrev,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < totalCount; i++)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(
horizontal: 2,
),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == currentIndex
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onNext,
),
],
),
],
],
),
],
),