From fe933a713faabeadbe7982119a59db00fc032983 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 8 May 2026 12:44:39 +0200 Subject: [PATCH] feat(studio): editorial Today hero on the store (v0.30.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-store research shows that the most loved discovery surfaces — Apple's Today tab being the canonical example — work because editors tell stories instead of stacking algorithmic recommendations. F∆I has no surveillance budget and no monetization pressure, so we lean fully into editorial curation. This commit adds `_StoreTodayHero`, the first surface a browsing operator sees: - Single curated story per release, kept const in [_kCurrentTodayStory] so the narrative is reviewable in code review and ships in the audit log via the binary hash. No CMS, no network, no surveillance. - Bilingual content shipped inline (`titleEn`/`titleDe`, `bodyEn`/`bodyDe`) so KRITIS deployments don't need a translation backend. - Gradient backdrop with hero icon, deck, narrative paragraph, optional CTA. Visual treatment matches Apple Today's hierarchy: badge → headline → body → action. - Auto-hides whenever a filter is active so a purposeful search isn't pushed below the fold. - Per-session dismiss button — no permanent suppression, the next Studio launch shows it again so a release-bumped story has a chance to be seen. Current story (Studio v0.30.x) directs operators to the new recommended-sources strip, closing the loop between editorial context and one-click action. Signed-off-by: flemming-it --- lib/l10n/app_de.arb | 3 + lib/l10n/app_en.arb | 3 + lib/l10n/app_localizations.dart | 18 +++ lib/l10n/app_localizations_de.dart | 9 ++ lib/l10n/app_localizations_en.dart | 9 ++ lib/main.dart | 2 +- lib/pages/store.dart | 229 +++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 8 files changed, 273 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 679c012..e4a10b8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -118,6 +118,9 @@ "placeholders": { "n": { "type": "int" } } }, "storeReloadTooltip": "Aktualisieren", + "storeTodayBadge": "HEUTE", + "storeTodayDeck": "Von der F∆I-Redaktion", + "storeTodayDismissTooltip": "Für diese Sitzung ausblenden", "storePillInstalled": "installiert", "storePillUpdate": "Update", "storeUpdateButton": "Update auf {version}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 424d3a4..3c98f5e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -119,6 +119,9 @@ "placeholders": { "n": { "type": "int" } } }, "storeReloadTooltip": "Reload", + "storeTodayBadge": "TODAY", + "storeTodayDeck": "From the F∆I editor", + "storeTodayDismissTooltip": "Hide for this session", "storePillInstalled": "installed", "storePillUpdate": "update", "storeUpdateButton": "Update to {version}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 42518e1..5a44e4d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -692,6 +692,24 @@ abstract class AppLocalizations { /// **'Reload'** String get storeReloadTooltip; + /// No description provided for @storeTodayBadge. + /// + /// In en, this message translates to: + /// **'TODAY'** + String get storeTodayBadge; + + /// No description provided for @storeTodayDeck. + /// + /// In en, this message translates to: + /// **'From the F∆I editor'** + String get storeTodayDeck; + + /// No description provided for @storeTodayDismissTooltip. + /// + /// In en, this message translates to: + /// **'Hide for this session'** + String get storeTodayDismissTooltip; + /// No description provided for @storePillInstalled. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 3f4d95a..01e0fbd 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -345,6 +345,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storeReloadTooltip => 'Aktualisieren'; + @override + String get storeTodayBadge => 'HEUTE'; + + @override + String get storeTodayDeck => 'Von der F∆I-Redaktion'; + + @override + String get storeTodayDismissTooltip => 'Für diese Sitzung ausblenden'; + @override String get storePillInstalled => 'installiert'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c3ee300..1a4e72c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -363,6 +363,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storeReloadTooltip => 'Reload'; + @override + String get storeTodayBadge => 'TODAY'; + + @override + String get storeTodayDeck => 'From the F∆I editor'; + + @override + String get storeTodayDismissTooltip => 'Hide for this session'; + @override String get storePillInstalled => 'installed'; diff --git a/lib/main.dart b/lib/main.dart index 05f1270..3fbd74e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.29.0'; +const String kStudioVersion = '0.30.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/store.dart b/lib/pages/store.dart index fc96d97..657fd1d 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -34,6 +34,10 @@ class _StorePageState extends State { /// Tracks per-recommended-source add buttons to disable them /// while the request is in flight. final Set _addingSources = {}; + /// Whether the operator dismissed the editorial hero this + /// session. Not persisted — the next Studio launch shows it + /// again so a release-bumped story has a chance to be seen. + bool _todayDismissed = false; late Future> _future; /// Active UI locale, read from the app-wide MaterialApp @@ -247,9 +251,30 @@ class _StorePageState extends State { _source.isEmpty && !_installedOnly && !raw.any((e) => e.isFederated); + // Editorial hero is browsing-only chrome — + // hides as soon as any filter is set so the + // operator's actual query stays in focus. + final showToday = !_todayDismissed && + _queryCtrl.text.trim().isEmpty && + _category.isEmpty && + _status.isEmpty && + _source.isEmpty && + !_installedOnly; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (showToday) ...[ + _StoreTodayHero( + story: _kCurrentTodayStory, + locale: _locale, + onDismiss: () => + setState(() => _todayDismissed = true), + onCta: _kCurrentTodayStory.cta == _TodayCta.openSettings + ? () => FaiSettingsDialog.show(context) + : null, + ), + const SizedBox(height: FaiSpace.md), + ], if (showRecommendedStrip) ...[ _RecommendedSourcesStrip( adding: _addingSources, @@ -1909,6 +1934,210 @@ class _ScreenshotPlaceholder extends StatelessWidget { } } +/// Editorial action attached to a `_TodayStory`. Kept as an +/// enum (not a closure) so the story list stays `const` and +/// thus shippable as a frozen, audit-friendly artefact. +enum _TodayCta { openSettings } + +/// Editorial story shown in the Today-hero strip. One story +/// is curated per release in [_kCurrentTodayStory] — operators +/// who launch Studio see the same narrative until the next +/// version bump. Bilingual content is shipped inline so KRITIS +/// deployments don't need to pull translations from a CMS. +class _TodayStory { + final String badgeEn; + final String badgeDe; + final String titleEn; + final String titleDe; + final String bodyEn; + final String bodyDe; + final String ctaLabelEn; + final String ctaLabelDe; + final IconData icon; + final _TodayCta cta; + + const _TodayStory({ + required this.badgeEn, + required this.badgeDe, + required this.titleEn, + required this.titleDe, + required this.bodyEn, + required this.bodyDe, + required this.ctaLabelEn, + required this.ctaLabelDe, + required this.icon, + required this.cta, + }); +} + +/// The story shown by Studio v0.30.x. Update when the +/// release-narrative changes — this is the editorial role of +/// the App Store Today tab, kept lightweight: one story, one +/// CTA, refreshed per release. +const _TodayStory _kCurrentTodayStory = _TodayStory( + badgeEn: 'CAPABILITY FEDERATION', + badgeDe: 'CAPABILITY-FEDERATION', + titleEn: 'Two MCP servers away from a richer hub', + titleDe: 'Zwei MCP-Server bis zu einem reicheren Hub', + bodyEn: + 'The store ships with bundled native modules and curated planned bridges. Click a recommended public source below — DeepWiki for repository docs, Semgrep for security scans — and synthetic `mcp..` capabilities appear here within seconds. No Node, no API key, no subprocess.', + bodyDe: + 'Der Store bringt native Module und kuratierte Bridge-Vorschläge mit. Klicke unten auf eine öffentliche Quelle — DeepWiki für Repo-Doku, Semgrep für Security-Scans — und synthetische `mcp..`-Capabilities erscheinen binnen Sekunden hier. Kein Node, kein API-Key, kein Subprozess.', + ctaLabelEn: 'Open MCP settings', + ctaLabelDe: 'MCP-Einstellungen öffnen', + icon: Icons.hub_outlined, + cta: _TodayCta.openSettings, +); + +/// Editorial hero strip — the first surface a browsing operator +/// sees. Plays the role of Apple's Today tab: one curated +/// story, gradient backdrop, narrative copy over feature +/// bullets. Auto-hides whenever a filter is active so a +/// purposeful search isn't pushed below the fold. +class _StoreTodayHero extends StatelessWidget { + final _TodayStory story; + final String locale; + final VoidCallback onDismiss; + /// CTA action — null hides the button. Wired by the parent + /// because navigation targets live in the store-page state, + /// not in const story data. + final VoidCallback? onCta; + + const _StoreTodayHero({ + required this.story, + required this.locale, + required this.onDismiss, + required this.onCta, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final isDe = locale == 'de'; + final badge = isDe ? story.badgeDe : story.badgeEn; + final title = isDe ? story.titleDe : story.titleEn; + final body = isDe ? story.bodyDe : story.bodyEn; + final ctaLabel = isDe ? story.ctaLabelDe : story.ctaLabelEn; + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB( + FaiSpace.xl, + FaiSpace.lg, + FaiSpace.md, + FaiSpace.lg, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FaiRadius.md), + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary.withValues(alpha: 0.16), + theme.colorScheme.primary.withValues(alpha: 0.04), + theme.colorScheme.surfaceContainer.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.55, 1.0], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.25), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.14), + shape: BoxShape.circle, + ), + child: Icon( + story.icon, + size: 24, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: FaiSpace.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FaiPill( + label: l.storeTodayBadge, + tone: FaiPillTone.accent, + ), + const SizedBox(width: FaiSpace.sm), + Text( + badge, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(width: FaiSpace.sm), + Text( + '·', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: FaiSpace.sm), + Text( + l.storeTodayDeck, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + fontSize: 10, + ), + ), + ], + ), + const SizedBox(height: FaiSpace.sm), + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.15, + ), + ), + const SizedBox(height: FaiSpace.sm), + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.45, + ), + ), + if (onCta != null) ...[ + const SizedBox(height: FaiSpace.md), + FilledButton.tonalIcon( + onPressed: onCta, + icon: const Icon(Icons.arrow_forward, size: 14), + label: Text(ctaLabel), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 16), + visualDensity: VisualDensity.compact, + tooltip: l.storeTodayDismissTooltip, + onPressed: onDismiss, + ), + ], + ), + ); + } +} + /// Curated public MCP server that the operator can register /// with one click — no Node, no API key, no subprocess. Fields /// are kept const so the strip can be a `const` widget tree. diff --git a/pubspec.yaml b/pubspec.yaml index 5b578bd..75bceae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.29.0 +version: 0.30.0 environment: sdk: ^3.11.0-200.1.beta