feat(studio): first-run UX, recovery affordances, l10n, bundled fonts

- Connection-aware Welcome: when the hub is down, show a hero with a
  primary "Start hub" CTA + install fallback instead of a dead,
  all-unchecked onboarding checklist (the first-run cliff).
- Actionable binary-not-found (file picker + install link, not a
  "set FAI_BIN" dead end) and a connect-failure banner after
  repeated failed health polls.
- Localize six hardcoded English error/toast clusters (DE+EN ARB).
- Bundle Inter + JetBrains Mono as assets; drop the runtime
  google_fonts fetch (air-gap / KRITIS safe, no font-swap flash).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-11 23:52:18 +02:00
parent 7511867774
commit 5313266cc4
25 changed files with 1231 additions and 234 deletions

View file

@ -28,6 +28,11 @@ class WelcomePage extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
// The shell polls hub health on a 5 s tick. When the hub is
// down a brand-new operator otherwise sees a dead, all-
// unchecked onboarding checklist with no obvious recovery
// so we lead with a "start the hub" hero instead.
final hubDown = StudioShellState.of(context)?.connected == false;
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(titleSpacing: FaiSpace.xl, title: Text(l.navWelcome)),
@ -52,22 +57,27 @@ class WelcomePage extends StatelessWidget {
constraints: const BoxConstraints(maxWidth: 960),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
_Hero(),
SizedBox(height: FaiSpace.xxl),
// Checklist comes second operator-actionable
// path beats educational content. Auto-hides
// once the operator dismisses it.
_OnboardingChecklist(),
_PillarRow(),
SizedBox(height: FaiSpace.xxl),
_SectionLabel(textKey: _SectionLabelKey.trust),
SizedBox(height: FaiSpace.md),
_TrustPosture(),
SizedBox(height: FaiSpace.xxl),
_SectionLabel(textKey: _SectionLabelKey.docs),
SizedBox(height: FaiSpace.md),
_DocsRow(),
children: [
const _Hero(),
const SizedBox(height: FaiSpace.xxl),
// When the hub is down the onboarding checklist
// would render dead (all-unchecked, nothing to
// probe). Lead with a "start the hub" card
// instead; restore the checklist once connected.
if (hubDown) ...[
const _HubDownHero(),
const SizedBox(height: FaiSpace.xxl),
] else
const _OnboardingChecklist(),
const _PillarRow(),
const SizedBox(height: FaiSpace.xxl),
const _SectionLabel(textKey: _SectionLabelKey.trust),
const SizedBox(height: FaiSpace.md),
const _TrustPosture(),
const SizedBox(height: FaiSpace.xxl),
const _SectionLabel(textKey: _SectionLabelKey.docs),
const SizedBox(height: FaiSpace.md),
const _DocsRow(),
],
),
),
@ -149,6 +159,113 @@ class _Hero extends StatelessWidget {
}
}
/// Prominent recovery hero shown on the Welcome page when the
/// hub is not reachable. Headline + one-line explanation + a big
/// "Start hub" CTA wired to the shell's daemon-start path, with
/// a secondary link to the install guide for the case where no
/// `fai` binary can be located.
class _HubDownHero extends StatefulWidget {
const _HubDownHero();
@override
State<_HubDownHero> createState() => _HubDownHeroState();
}
class _HubDownHeroState extends State<_HubDownHero> {
bool _starting = false;
Future<void> _start() async {
final shell = StudioShellState.of(context);
if (shell == null) return;
setState(() => _starting = true);
await shell.startDaemon();
if (!mounted) return;
setState(() => _starting = false);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
// No `fai` binary "Start hub" cannot work; lead the
// operator to the install guide instead.
final canStart = SystemActions.faiBinaryExists();
return Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.xl),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.4),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.cloud_off_outlined,
size: 24,
color: theme.colorScheme.error,
),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Text(
l.welcomeHubDownTitle,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: FaiSpace.md),
Text(
l.welcomeHubDownBody,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.45,
),
),
const SizedBox(height: FaiSpace.xs),
Text(
l.welcomeHubDownEndpoint(HubService.instance.endpointLabel),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.lg),
if (canStart)
FilledButton.icon(
onPressed: _starting ? null : _start,
icon: _starting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_arrow),
label: Text(
_starting ? l.welcomeHubDownStarting : l.welcomeHubDownStart,
),
)
else
const FaiBinaryRecovery(),
const SizedBox(height: FaiSpace.sm),
TextButton.icon(
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(l.welcomeHubDownDocsLink),
onPressed: () => SystemActions.openInOs(kFaiInstallDocsUrl),
),
],
),
);
}
}
enum _SectionLabelKey { trust, docs }
class _SectionLabel extends StatelessWidget {
@ -904,58 +1021,82 @@ class _DocsRow extends StatelessWidget {
}
}
class _DocCard extends StatelessWidget {
class _DocCard extends StatefulWidget {
final _DocEntry entry;
const _DocCard({required this.entry});
@override
State<_DocCard> createState() => _DocCardState();
}
class _DocCardState extends State<_DocCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: InkWell(
onTap: () => _DocReaderSheet.show(context, entry),
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Container(
padding: const EdgeInsets.all(FaiSpace.lg),
decoration: BoxDecoration(
final entry = widget.entry;
// Hover-lift mirrors the Store cards: a gentle 2px rise plus
// shadow bump. Resting state stays flat-with-border so the
// doc strip reads calm until the cursor invites a click.
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: FaiMotion.base,
curve: FaiMotion.easing,
transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
boxShadow: _hovered ? FaiElevation.medium(theme.brightness) : null,
),
child: Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: InkWell(
onTap: () => _DocReaderSheet.show(context, entry),
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(entry.icon, size: 22, color: theme.colorScheme.primary),
const SizedBox(width: FaiSpace.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.title(l),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
entry.blurb(l),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
),
child: Container(
padding: const EdgeInsets.all(FaiSpace.lg),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
Icon(
Icons.chevron_right,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(entry.icon, size: 22, color: theme.colorScheme.primary),
const SizedBox(width: FaiSpace.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.title(l),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
entry.blurb(l),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
),
),
Icon(
Icons.chevron_right,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
],
),
),
),
),
@ -983,6 +1124,15 @@ class _DocReaderError {
String get forgejoUrl =>
'https://git.flemming.ai/fai/platform/src/branch/main/docs/architecture/$slug.md';
/// Localized, operator-facing failure body. Use this for any
/// on-screen rendering; [toString] stays English for logs.
String localizedBody(AppLocalizations l) => l.welcomeDocLoadFailedBody(
slug,
locale,
attempted.join(', '),
underlying,
);
@override
String toString() =>
'Could not load the bundled doc "$slug" ($locale).\n'
@ -1006,6 +1156,7 @@ class _DocReaderSheet extends StatefulWidget {
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
),
@ -1135,7 +1286,18 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
),
),
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(error: err, isError: true, maxHeight: 200),
if (structured != null)
FaiErrorBox(
text: structured.localizedBody(l),
isError: true,
maxHeight: 200,
)
else
FaiErrorBox(
error: err,
isError: true,
maxHeight: 200,
),
if (structured != null) ...[
const SizedBox(height: FaiSpace.md),
TextButton.icon(