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
/// running app self-identifies visible in the sidebar header
/// 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 {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -232,41 +232,34 @@ class _StorePageState extends State<StorePage> {
),
);
}
// Featured strip is only useful in the
// browse-the-whole-thing case. Once the
// operator has typed a query or picked a
// filter, the result list itself is the
// 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 &&
// Browsing-only chrome every editorial
// surface hides the moment a filter is set so
// the operator's query stays the focus.
final isBrowsing = _queryCtrl.text.trim().isEmpty &&
_category.isEmpty &&
_status.isEmpty &&
_source.isEmpty &&
!_installedOnly;
return Column(
final hasNoFederation = !raw.any((e) => e.isFederated);
final showToday = !_todayDismissed && isBrowsing;
// Recommended-source quick-adds are inlined in
// the Today footer when both apply, so the
// operator never sees two competing editorial
// cards stacked on top of each other.
final embedRecommended = showToday && hasNoFederation;
// Standalone strip only fires in the corner
// case: Today dismissed AND no federation.
// Featured strip and Today never compete
// Today is the editorial hero, Featured plays
// second fiddle and only renders once Today is
// out of the way.
final showStandaloneRecommended =
isBrowsing && _todayDismissed && hasNoFederation;
final showFeatured = isBrowsing &&
_todayDismissed &&
items.any((e) => e.featured);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showToday) ...[
@ -278,16 +271,21 @@ class _StorePageState extends State<StorePage> {
onCta: _todayStory.cta == TodayCta.openSettings
? () => FaiSettingsDialog.show(context)
: null,
recommendedSources: embedRecommended
? _kRecommendedSources
: const <_RecommendedSource>[],
recommendedAdding: _addingSources,
onAddRecommended: _addRecommendedSource,
),
const SizedBox(height: FaiSpace.md),
const SizedBox(height: FaiSpace.lg),
],
if (showRecommendedStrip) ...[
if (showStandaloneRecommended) ...[
_RecommendedSourcesStrip(
adding: _addingSources,
locale: _locale,
onAdd: _addRecommendedSource,
),
const SizedBox(height: FaiSpace.md),
const SizedBox(height: FaiSpace.lg),
],
if (showFeatured) ...[
_FeaturedStrip(
@ -298,16 +296,15 @@ class _StorePageState extends State<StorePage> {
),
const SizedBox(height: FaiSpace.lg),
],
Expanded(
child: _StoreGrid(
_StoreGrid(
items: items,
locale: _locale,
installedVersions: _installedVersions,
onTap: _openDetail,
onInstall: _install,
),
),
],
),
);
},
),
@ -603,8 +600,16 @@ class _StoreGrid extends StatelessWidget {
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: FaiSpace.md,
@ -1972,12 +1977,23 @@ class _StoreTodayHero extends StatelessWidget {
/// because navigation targets live in the store-page state,
/// not in const story data.
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({
required this.story,
required this.locale,
required this.onDismiss,
required this.onCta,
this.recommendedSources = const <_RecommendedSource>[],
this.recommendedAdding = const <String>{},
this.onAddRecommended,
});
@override
@ -2082,8 +2098,14 @@ class _StoreTodayHero extends StatelessWidget {
height: 1.45,
),
),
if (onCta != null) ...[
if (onCta != null || recommendedSources.isNotEmpty) ...[
const SizedBox(height: FaiSpace.md),
Wrap(
spacing: FaiSpace.sm,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (onCta != null)
FilledButton.tonalIcon(
onPressed: onCta,
icon: const Icon(Icons.arrow_forward, size: 14),
@ -2092,6 +2114,24 @@ class _StoreTodayHero extends StatelessWidget {
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,
);
default:
return FaiPill(
label: l.storeProvenanceNative,
tone: FaiPillTone.neutral,
);
// Native is the default: no badge needed. Keeps the
// card visually quiet only foreign code (mcp / n8n)
// 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
description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none'
version: 0.31.0
version: 0.31.1
environment:
sdk: ^3.11.0-200.1.beta