fix(studio): store overflow + visual clutter (v0.31.1)

Two issues from the user feedback after v0.31.0:

1. RenderFlex overflow in the store body. The inner Column
   wrapped the editorial chrome (Today, Recommended,
   Featured) plus an Expanded grid; on small windows the
   chrome alone exceeded the available height and the grid
   got squeezed past zero, painting the yellow-and-black
   stripe.

   Fix: wrap the inner Column in a SingleChildScrollView so
   chrome and grid scroll as one continuous surface
   (App-Store / Play-Store behaviour). The grid switches to
   `shrinkWrap: true` + `NeverScrollableScrollPhysics()` so
   it doesn't try to claim its own viewport.

2. "Der Store ist zu unaufgeräumt, ich fühle mich
   erschlagen." — three editorial cards stacked above the
   filter row + grid was too much.

   Visual cleanup:
   - The recommended-source quick-add chips are now inlined
     into the Today hero's footer (same row as the CTA).
     The standalone `_RecommendedSourcesStrip` only fires in
     the corner case where Today is dismissed AND no
     federation exists yet.
   - The Featured strip no longer competes with Today —
     it only renders once Today has been dismissed. Today
     plays the editorial-hero role; Featured is the
     fallback editorial surface.
   - Provenance pill ("native") is dropped on native cards.
     Native is the default and doesn't earn a badge — only
     foreign code (mcp / n8n) gets the provenance pill, the
     same way "verified" is opt-in in commercial app stores.

Net: at most one editorial card above the grid at any time.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 13:06:54 +02:00
parent ce97300a12
commit b0f09260e1
3 changed files with 121 additions and 80 deletions

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.31.0'; const String kStudioVersion = '0.31.1';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();

View file

@ -232,82 +232,79 @@ class _StorePageState extends State<StorePage> {
), ),
); );
} }
// Featured strip is only useful in the // Browsing-only chrome every editorial
// browse-the-whole-thing case. Once the // surface hides the moment a filter is set so
// operator has typed a query or picked a // the operator's query stays the focus.
// filter, the result list itself is the final isBrowsing = _queryCtrl.text.trim().isEmpty &&
// answer they want featured chrome would
// just push it down.
final showFeatured = _queryCtrl.text.trim().isEmpty &&
_category.isEmpty &&
_status.isEmpty &&
_source.isEmpty &&
!_installedOnly &&
items.any((e) => e.featured);
// Sparse-store nudge: when no filter is set
// and the store carries no federated entries
// yet, surface curated public MCP servers as
// one-click add cards. Hides the moment any
// federated entry shows up the strip has
// done its job.
final showRecommendedStrip =
_queryCtrl.text.trim().isEmpty &&
_category.isEmpty &&
_status.isEmpty &&
_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 && _category.isEmpty &&
_status.isEmpty && _status.isEmpty &&
_source.isEmpty && _source.isEmpty &&
!_installedOnly; !_installedOnly;
return Column( final hasNoFederation = !raw.any((e) => e.isFederated);
crossAxisAlignment: CrossAxisAlignment.start, final showToday = !_todayDismissed && isBrowsing;
children: [ // Recommended-source quick-adds are inlined in
if (showToday) ...[ // the Today footer when both apply, so the
_StoreTodayHero( // operator never sees two competing editorial
story: _todayStory, // cards stacked on top of each other.
locale: _locale, final embedRecommended = showToday && hasNoFederation;
onDismiss: () => // Standalone strip only fires in the corner
setState(() => _todayDismissed = true), // case: Today dismissed AND no federation.
onCta: _todayStory.cta == TodayCta.openSettings // Featured strip and Today never compete
? () => FaiSettingsDialog.show(context) // Today is the editorial hero, Featured plays
: null, // second fiddle and only renders once Today is
), // out of the way.
const SizedBox(height: FaiSpace.md), final showStandaloneRecommended =
], isBrowsing && _todayDismissed && hasNoFederation;
if (showRecommendedStrip) ...[ final showFeatured = isBrowsing &&
_RecommendedSourcesStrip( _todayDismissed &&
adding: _addingSources, items.any((e) => e.featured);
locale: _locale, return SingleChildScrollView(
onAdd: _addRecommendedSource, child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: FaiSpace.md), children: [
], if (showToday) ...[
if (showFeatured) ...[ _StoreTodayHero(
_FeaturedStrip( story: _todayStory,
items: items.where((e) => e.featured).toList(), locale: _locale,
locale: _locale, onDismiss: () =>
onTap: _openDetail, setState(() => _todayDismissed = true),
onInstall: _install, onCta: _todayStory.cta == TodayCta.openSettings
), ? () => FaiSettingsDialog.show(context)
const SizedBox(height: FaiSpace.lg), : null,
], recommendedSources: embedRecommended
Expanded( ? _kRecommendedSources
child: _StoreGrid( : const <_RecommendedSource>[],
recommendedAdding: _addingSources,
onAddRecommended: _addRecommendedSource,
),
const SizedBox(height: FaiSpace.lg),
],
if (showStandaloneRecommended) ...[
_RecommendedSourcesStrip(
adding: _addingSources,
locale: _locale,
onAdd: _addRecommendedSource,
),
const SizedBox(height: FaiSpace.lg),
],
if (showFeatured) ...[
_FeaturedStrip(
items: items.where((e) => e.featured).toList(),
locale: _locale,
onTap: _openDetail,
onInstall: _install,
),
const SizedBox(height: FaiSpace.lg),
],
_StoreGrid(
items: items, items: items,
locale: _locale, locale: _locale,
installedVersions: _installedVersions, installedVersions: _installedVersions,
onTap: _openDetail, onTap: _openDetail,
onInstall: _install, onInstall: _install,
), ),
), ],
], ),
); );
}, },
), ),
@ -603,8 +600,16 @@ class _StoreGrid extends StatelessWidget {
builder: (context, constraints) { builder: (context, constraints) {
const minCardWidth = 360.0; const minCardWidth = 360.0;
final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4); 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( return GridView.builder(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols, crossAxisCount: cols,
mainAxisSpacing: FaiSpace.md, mainAxisSpacing: FaiSpace.md,
@ -1972,12 +1977,23 @@ class _StoreTodayHero extends StatelessWidget {
/// because navigation targets live in the store-page state, /// because navigation targets live in the store-page state,
/// not in const story data. /// not in const story data.
final VoidCallback? onCta; final VoidCallback? onCta;
/// Inline one-click "+ Add public source" chips rendered in
/// the hero footer. Empty list hides the row the parent
/// passes [_kRecommendedSources] only when the store has zero
/// federated entries, so we never duplicate the standalone
/// strip.
final List<_RecommendedSource> recommendedSources;
final Set<String> recommendedAdding;
final void Function(_RecommendedSource)? onAddRecommended;
const _StoreTodayHero({ const _StoreTodayHero({
required this.story, required this.story,
required this.locale, required this.locale,
required this.onDismiss, required this.onDismiss,
required this.onCta, required this.onCta,
this.recommendedSources = const <_RecommendedSource>[],
this.recommendedAdding = const <String>{},
this.onAddRecommended,
}); });
@override @override
@ -2082,15 +2098,39 @@ class _StoreTodayHero extends StatelessWidget {
height: 1.45, height: 1.45,
), ),
), ),
if (onCta != null) ...[ if (onCta != null || recommendedSources.isNotEmpty) ...[
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
FilledButton.tonalIcon( Wrap(
onPressed: onCta, spacing: FaiSpace.sm,
icon: const Icon(Icons.arrow_forward, size: 14), runSpacing: FaiSpace.xs,
label: Text(ctaLabel), crossAxisAlignment: WrapCrossAlignment.center,
style: FilledButton.styleFrom( children: [
visualDensity: VisualDensity.compact, if (onCta != null)
), FilledButton.tonalIcon(
onPressed: onCta,
icon: const Icon(Icons.arrow_forward, size: 14),
label: Text(ctaLabel),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
for (final s in recommendedSources)
ActionChip(
avatar: recommendedAdding.contains(s.name)
? const SizedBox(
width: 12,
height: 12,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: Icon(s.icon, size: 14),
label: Text('+ ${s.label}'),
onPressed:
recommendedAdding.contains(s.name) || onAddRecommended == null
? null
: () => onAddRecommended!(s),
),
],
), ),
], ],
], ],
@ -2340,10 +2380,11 @@ class _ProvenancePill extends StatelessWidget {
icon: Icons.account_tree_outlined, icon: Icons.account_tree_outlined,
); );
default: default:
return FaiPill( // Native is the default: no badge needed. Keeps the
label: l.storeProvenanceNative, // card visually quiet only foreign code (mcp / n8n)
tone: FaiPillTone.neutral, // earns the provenance pill, the same way a "verified"
); // badge is opt-in in commercial app stores.
return const SizedBox.shrink();
} }
} }
} }

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.31.0 version: 0.31.1
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta