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

@ -2,55 +2,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:google_fonts/google_fonts.dart';
import 'tokens.dart';
class FaiTheme {
FaiTheme._();
/// Bundled font families. Declared in pubspec.yaml `fonts:` and
/// shipped as TTF assets under `assets/fonts/` NOT fetched at
/// runtime via google_fonts. This keeps the app working fully
/// offline / air-gapped (KRITIS) and avoids the font-swap flash
/// google_fonts shows on first paint.
static const String _uiFamily = 'Inter';
static const String _monoFamily = 'JetBrains Mono';
/// Inter for UI, JetBrains Mono for technical strings (IDs,
/// paths, capability refs). Both via google_fonts so they
/// bundle on first run; for production we'd ship them as
/// app assets.
/// paths, capability refs). Both bundled (see [_uiFamily]).
static TextTheme _textTheme(Brightness b) {
final base = b == Brightness.dark
? Typography.whiteCupertino
: Typography.blackCupertino;
return GoogleFonts.interTextTheme(base).copyWith(
displaySmall: GoogleFonts.inter(
return base.apply(fontFamily: _uiFamily).copyWith(
displaySmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
),
headlineSmall: GoogleFonts.inter(
headlineSmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: GoogleFonts.inter(
titleMedium: const TextStyle(
fontFamily: _uiFamily,
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
titleSmall: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500),
bodyLarge: GoogleFonts.inter(
titleSmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 13,
fontWeight: FontWeight.w500,
),
bodyLarge: const TextStyle(
fontFamily: _uiFamily,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.45,
),
bodyMedium: GoogleFonts.inter(
bodyMedium: const TextStyle(
fontFamily: _uiFamily,
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.4,
),
bodySmall: GoogleFonts.inter(
bodySmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.4,
),
labelMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w500),
labelSmall: GoogleFonts.inter(
labelMedium: const TextStyle(
fontFamily: _uiFamily,
fontSize: 12,
fontWeight: FontWeight.w500,
),
labelSmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
@ -59,12 +79,14 @@ class FaiTheme {
}
/// JetBrains Mono for monospaced technical content. Used by
/// FaiMono helper.
/// FaiMono helper. Only Regular (400) and Medium (500) weights
/// are bundled heavier requests fall back to the nearest.
static TextStyle mono({
double size = 12,
FontWeight weight = FontWeight.w400,
Color? color,
}) => GoogleFonts.jetBrainsMono(
}) => TextStyle(
fontFamily: _monoFamily,
fontSize: size,
fontWeight: weight,
color: color,
@ -221,8 +243,8 @@ class FaiTheme {
scrolledUnderElevation: 0,
centerTitle: false,
// Bake the foreground colour straight into the title
// style. `headlineSmall` is built from GoogleFonts.inter
// with no color slot, and Material's "merge foreground
// style. `headlineSmall` is built from the bundled Inter
// family with no color slot, and Material's "merge foreground
// colour at draw time" path leaves it null in some
// light-mode builds the title rendered white-on-white.
titleTextStyle: textTheme.headlineSmall?.copyWith(