chain-studio/lib/theme/theme.dart
flemming-it 69b54629d3 chore(studio): flutter_markdown_plus migration + integration-test scaffold
Two follow-ups to the May-2026 trust pass.

flutter_markdown_plus migration:

- pubspec swaps `flutter_markdown ^0.7.7` (discontinued
  upstream) for `flutter_markdown_plus ^1.0.3`, the
  actively-maintained fork. API surface
  (MarkdownStyleSheet, Markdown, MarkdownBody) is
  unchanged — the four import sites in welcome, store,
  flow_output, and theme.dart get an updated package
  string and that's it.
- All Studio analyzer + unit-test suites stay green.

Integration test scaffold:

- New `test/integration/hub_fixture.dart` boots a real
  `fai serve` subprocess on a free port against a temp
  FAI_DATA_DIR, polls until Healthy, exposes a ready
  HubClient. Idempotent teardown wipes the temp dir.
- Resolves the `fai` binary from PATH first, then from
  `../fai_platform/target/release/fai`. When neither
  exists, the fixture calls `markTestSkipped` with a
  clear message — fresh checkouts don't fail.
- One canonical test in `capabilities_test.dart` asserts
  on the bug class the May trust pass surfaced: that
  `system.approval` appears in `list_capabilities` with
  `kind=builtin` so Studio's missing-deps check never
  tries to install it. Plus a contract-shape test that
  every cap's `kind` is one of the three known wire
  values.
- README documents the cold-start gotcha (first
  `fai serve` per machine takes ~30s to build the
  curated-model DB) plus the manual warmup recipe.

Not in CI yet — wiring needs the platform build job to
publish `fai` as a CI artifact for downstream consumption.
Deferred until enough integration tests exist to justify
the CI minutes.

Signed-off-by: flemming-it <sf@flemming.it>
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-25 13:22:04 +02:00

308 lines
10 KiB
Dart

// Theme assembly. ColorScheme + typography + component themes.
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._();
/// 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.
static TextTheme _textTheme(Brightness b) {
final base = b == Brightness.dark
? Typography.whiteCupertino
: Typography.blackCupertino;
return GoogleFonts.interTextTheme(base).copyWith(
displaySmall: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
),
headlineSmall: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
titleSmall: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
),
bodyLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.45,
),
bodyMedium: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.4,
),
bodySmall: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.4,
),
labelMedium: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
),
labelSmall: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
),
);
}
/// JetBrains Mono for monospaced technical content. Used by
/// FaiMono helper.
static TextStyle mono({
double size = 12,
FontWeight weight = FontWeight.w400,
Color? color,
}) =>
GoogleFonts.jetBrainsMono(
fontSize: size,
fontWeight: weight,
color: color,
height: 1.4,
);
/// Shared markdown style used by every inline doc renderer
/// (Welcome DocReaderSheet, Store module-detail DocsPanel,
/// future help surfaces). Centralising it means every doc
/// reads in the same typography, with the same dark-theme
/// safe colors.
///
/// Why every property is set explicitly even when it just
/// re-states the default:
///
/// - `flutter_markdown`'s `MarkdownStyleSheet.fromTheme(theme)`
/// sets `code` with `backgroundColor: theme.cardTheme.color`.
/// That per-span backgroundColor is rendered behind every
/// character inside a fenced code block, which on a dark
/// theme shows up as horizontal bands behind ASCII diagrams.
/// We force `backgroundColor: Colors.transparent` to suppress
/// it — the codeblock's own decoration provides the panel
/// background.
/// - `blockquote` defaults to a hardcoded `Colors.blue.shade100`
/// background that is unreadable in dark mode. We replace it
/// with theme-aware tokens (surfaceContainer + left accent
/// border).
/// - `tableCellsDecoration` has the same dark-mode bug, fixed
/// the same way.
static MarkdownStyleSheet markdownStyle(ThemeData theme) {
return MarkdownStyleSheet.fromTheme(theme).copyWith(
p: theme.textTheme.bodyMedium?.copyWith(height: 1.5),
code: mono(
size: 12,
color: theme.colorScheme.onSurface,
).copyWith(
backgroundColor: Colors.transparent,
),
codeblockDecoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
codeblockPadding: const EdgeInsets.all(FaiSpace.md),
blockquote: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
blockquoteDecoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border(
left: BorderSide(
color: theme.colorScheme.primary,
width: 3,
),
),
),
blockquotePadding: const EdgeInsets.fromLTRB(
FaiSpace.md,
FaiSpace.sm,
FaiSpace.sm,
FaiSpace.sm,
),
tableCellsDecoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
),
);
}
static ThemeData dark() {
final scheme = ColorScheme(
brightness: Brightness.dark,
primary: FaiColors.accent,
onPrimary: FaiColors.black,
primaryContainer: FaiColors.accentMuted,
onPrimaryContainer: FaiColors.textStrong,
secondary: FaiColors.accentDeep,
onSecondary: FaiColors.black,
secondaryContainer: FaiColors.surfaceHigh,
onSecondaryContainer: FaiColors.text,
tertiary: FaiColors.accent,
onTertiary: FaiColors.black,
tertiaryContainer: FaiColors.surfaceHigh,
onTertiaryContainer: FaiColors.text,
error: FaiColors.danger,
onError: FaiColors.textStrong,
errorContainer: const Color(0xFF3F1518),
onErrorContainer: const Color(0xFFFCA5A5),
surface: FaiColors.black,
onSurface: FaiColors.text,
onSurfaceVariant: FaiColors.muted,
outline: FaiColors.border,
outlineVariant: FaiColors.border,
surfaceContainerLowest: FaiColors.black,
surfaceContainerLow: FaiColors.surface,
surfaceContainer: FaiColors.surface,
surfaceContainerHigh: FaiColors.surfaceHigh,
surfaceContainerHighest: FaiColors.surfaceHigh,
);
return _build(scheme);
}
static ThemeData light() {
final scheme = ColorScheme(
brightness: Brightness.light,
primary: FaiColors.accentDeep,
onPrimary: FaiColors.lightSurface,
primaryContainer: const Color(0xFFE0F2FE),
onPrimaryContainer: FaiColors.accentMuted,
secondary: FaiColors.accentMuted,
onSecondary: FaiColors.lightSurface,
secondaryContainer: FaiColors.lightSurfaceHigh,
onSecondaryContainer: FaiColors.lightText,
tertiary: FaiColors.accentDeep,
onTertiary: FaiColors.lightSurface,
tertiaryContainer: const Color(0xFFE0F2FE),
onTertiaryContainer: FaiColors.accentMuted,
error: FaiColors.danger,
onError: FaiColors.lightSurface,
errorContainer: const Color(0xFFFEE2E2),
onErrorContainer: const Color(0xFF7F1D1D),
surface: FaiColors.lightCanvas,
onSurface: FaiColors.lightText,
onSurfaceVariant: FaiColors.lightMuted,
outline: FaiColors.lightBorder,
outlineVariant: FaiColors.lightBorder,
surfaceContainerLowest: FaiColors.lightCanvas,
surfaceContainerLow: FaiColors.lightSurface,
surfaceContainer: FaiColors.lightSurface,
surfaceContainerHigh: FaiColors.lightSurfaceHigh,
surfaceContainerHighest: FaiColors.lightSurfaceHigh,
);
return _build(scheme);
}
static ThemeData _build(ColorScheme scheme) {
final textTheme = _textTheme(scheme.brightness);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
textTheme: textTheme,
primaryTextTheme: textTheme,
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
iconTheme: IconThemeData(color: scheme.onSurface),
elevation: 0,
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
// colour at draw time" path leaves it null in some
// light-mode builds — the title rendered white-on-white.
titleTextStyle: textTheme.headlineSmall?.copyWith(
color: scheme.onSurface,
),
toolbarHeight: 64,
),
cardTheme: CardThemeData(
color: scheme.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
side: BorderSide(color: scheme.outlineVariant),
),
margin: EdgeInsets.zero,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.lg,
vertical: FaiSpace.md,
),
textStyle: textTheme.labelMedium,
animationDuration: FaiMotion.fast,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.onSurface,
side: BorderSide(color: scheme.outline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.lg,
vertical: FaiSpace.md,
),
textStyle: textTheme.labelMedium,
animationDuration: FaiMotion.fast,
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: scheme.onSurfaceVariant,
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: scheme.surfaceContainerLow,
indicatorColor: scheme.primaryContainer,
selectedIconTheme: IconThemeData(color: scheme.primary, size: 22),
unselectedIconTheme:
IconThemeData(color: scheme.onSurfaceVariant, size: 22),
selectedLabelTextStyle:
textTheme.labelMedium?.copyWith(color: scheme.primary),
unselectedLabelTextStyle: textTheme.labelMedium
?.copyWith(color: scheme.onSurfaceVariant),
useIndicator: true,
),
dividerColor: scheme.outlineVariant,
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
thickness: 1,
space: 1,
),
snackBarTheme: SnackBarThemeData(
backgroundColor: scheme.surfaceContainerHigh,
contentTextStyle:
textTheme.bodyMedium?.copyWith(color: scheme.onSurface),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
),
);
}
}