chain-studio/lib/theme/theme.dart
flemming-it c1c60d5434
Some checks failed
Security / Security check (push) Failing after 1s
feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
Settings dialog's Theme Plugin section is now a grid of
swatched tiles:

- Built-in (none) — falls back to FaiTheme.light/.dark
- One tile per installed studio.theme.* plugin, each
  showing the plugin's primary/secondary/tertiary as
  live colour dots. Tile loads its preview lazily so a
  dozen installed themes don't block the picker.
- Custom — opens a colour-picker dialog with 12 curated
  Material presets + a hex input + live preview. Selecting
  applies ColorScheme.fromSeed for both brightnesses.

main.dart's _pluginThemes parses a 'custom:#RRGGBB' sigil
in the same notifier slot as plugin capability ids, so the
existing persistence + restoration paths cover the custom
case with no new state.

Bumps editor to 0.11.0 (type-checked port connections +
dynamic card width fix + card-height border allowance) and
Studio to 0.58.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-02 01:29:34 +02:00

309 lines
11 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,
),
);
}
/// Build a Studio ThemeData around any [ColorScheme]. Used by
/// the built-in light/dark constructors and by
/// `themeDataFromScheme` when a Studio plugin supplies the
/// palette. Single entry-point so the plugin path and the
/// built-in path always produce structurally identical
/// ThemeData (same textTheme, same component themes); without
/// this, swapping between them at runtime trips Flutter's
/// TextStyle.lerp with mismatched `inherit` values.
static ThemeData fromColorScheme(ColorScheme scheme) => _build(scheme);
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),
),
),
);
}
}