feat(studio): editorial Today hero on the store (v0.30.0)

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 12:44:39 +02:00
parent 581ebbeaf7
commit fe933a713f
8 changed files with 273 additions and 2 deletions

View file

@ -118,6 +118,9 @@
"placeholders": { "n": { "type": "int" } } "placeholders": { "n": { "type": "int" } }
}, },
"storeReloadTooltip": "Aktualisieren", "storeReloadTooltip": "Aktualisieren",
"storeTodayBadge": "HEUTE",
"storeTodayDeck": "Von der F∆I-Redaktion",
"storeTodayDismissTooltip": "Für diese Sitzung ausblenden",
"storePillInstalled": "installiert", "storePillInstalled": "installiert",
"storePillUpdate": "Update", "storePillUpdate": "Update",
"storeUpdateButton": "Update auf {version}", "storeUpdateButton": "Update auf {version}",

View file

@ -119,6 +119,9 @@
"placeholders": { "n": { "type": "int" } } "placeholders": { "n": { "type": "int" } }
}, },
"storeReloadTooltip": "Reload", "storeReloadTooltip": "Reload",
"storeTodayBadge": "TODAY",
"storeTodayDeck": "From the F∆I editor",
"storeTodayDismissTooltip": "Hide for this session",
"storePillInstalled": "installed", "storePillInstalled": "installed",
"storePillUpdate": "update", "storePillUpdate": "update",
"storeUpdateButton": "Update to {version}", "storeUpdateButton": "Update to {version}",

View file

@ -692,6 +692,24 @@ abstract class AppLocalizations {
/// **'Reload'** /// **'Reload'**
String get storeReloadTooltip; 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. /// No description provided for @storePillInstalled.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -345,6 +345,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get storeReloadTooltip => 'Aktualisieren'; 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 @override
String get storePillInstalled => 'installiert'; String get storePillInstalled => 'installiert';

View file

@ -363,6 +363,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get storeReloadTooltip => 'Reload'; 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 @override
String get storePillInstalled => 'installed'; String get storePillInstalled => 'installed';

View file

@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.29.0'; const String kStudioVersion = '0.30.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();

View file

@ -34,6 +34,10 @@ class _StorePageState extends State<StorePage> {
/// Tracks per-recommended-source add buttons to disable them /// Tracks per-recommended-source add buttons to disable them
/// while the request is in flight. /// while the request is in flight.
final Set<String> _addingSources = <String>{}; final Set<String> _addingSources = <String>{};
/// 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<List<StoreItem>> _future; late Future<List<StoreItem>> _future;
/// Active UI locale, read from the app-wide MaterialApp /// Active UI locale, read from the app-wide MaterialApp
@ -247,9 +251,30 @@ class _StorePageState extends State<StorePage> {
_source.isEmpty && _source.isEmpty &&
!_installedOnly && !_installedOnly &&
!raw.any((e) => e.isFederated); !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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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) ...[ if (showRecommendedStrip) ...[
_RecommendedSourcesStrip( _RecommendedSourcesStrip(
adding: _addingSources, 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.<server>.<tool>` 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.<server>.<tool>`-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 /// Curated public MCP server that the operator can register
/// with one click no Node, no API key, no subprocess. Fields /// with one click no Node, no API key, no subprocess. Fields
/// are kept const so the strip can be a `const` widget tree. /// are kept const so the strip can be a `const` widget tree.

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.29.0 version: 0.30.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta