chain-studio/lib/theme/theme.dart
flemming-it da58125f20
Some checks are pending
Security / Security check (push) Waiting to run
fix(ui): WCAG light-theme accent, overflow-safe audit page, formal address
Accessibility/responsive audit pass with two new permanent test
gates (test/a11y_test.dart: WCAG text contrast + labeled tap
targets on every page in both themes; test/responsive_test.dart:
no layout overflow at 800/960/1280/1920 px). Findings fixed:

- Light theme primary/tertiary sky-500 → sky-700: white text on
  the lighter accent only reached 2.8:1 (welcome CTA, active
  sidebar label); sky-700 clears WCAG AA at ~5.9:1. Dark theme
  unchanged (already compliant). FABs now follow the same accent
  instead of Material 3's washed-out tonal default.
- Audit page: filter chips collapse into a checkmark popup menu
  below 900 px window width (app bar overflowed); the live-status
  bar's left text is now Expanded with ellipsis so the row can
  shrink, and the disconnected state's copyable error gets the
  full remaining width.
- German strings now use formal address consistently (~20 strings
  still used du-forms next to Sie-forms on welcome/setup), the
  audit event-type chip "Step" is "Schritt", and the doctor
  page's event count pluralises correctly in both languages.

flutter analyze clean, 64 tests green. Screenshot pass light+dark
via the guide-shots harness (verified parity, no overflows).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-15 04:34:19 +02:00

342 lines
12 KiB
Dart

// Theme assembly. ColorScheme + typography + component themes.
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'tokens.dart';
class ChainTheme {
ChainTheme._();
/// 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 bundled (see [_uiFamily]).
static TextTheme _textTheme(Brightness b) {
final base = b == Brightness.dark
? Typography.whiteCupertino
: Typography.blackCupertino;
return base.apply(fontFamily: _uiFamily).copyWith(
displaySmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
),
headlineSmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: const TextStyle(
fontFamily: _uiFamily,
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
titleSmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 13,
fontWeight: FontWeight.w500,
),
bodyLarge: const TextStyle(
fontFamily: _uiFamily,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.45,
),
bodyMedium: const TextStyle(
fontFamily: _uiFamily,
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.4,
),
bodySmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.4,
),
labelMedium: const TextStyle(
fontFamily: _uiFamily,
fontSize: 12,
fontWeight: FontWeight.w500,
),
labelSmall: const TextStyle(
fontFamily: _uiFamily,
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
),
);
}
/// JetBrains Mono for monospaced technical content. Used by
/// ChainMono 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,
}) => TextStyle(
fontFamily: _monoFamily,
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(ChainRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
codeblockPadding: const EdgeInsets.all(ChainSpace.md),
blockquote: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
blockquoteDecoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(ChainRadius.sm),
border: Border(
left: BorderSide(color: theme.colorScheme.primary, width: 3),
),
),
blockquotePadding: const EdgeInsets.fromLTRB(
ChainSpace.md,
ChainSpace.sm,
ChainSpace.sm,
ChainSpace.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: ChainColors.accent,
onPrimary: ChainColors.black,
primaryContainer: ChainColors.accentMuted,
onPrimaryContainer: ChainColors.textStrong,
secondary: ChainColors.accentDeep,
onSecondary: ChainColors.black,
secondaryContainer: ChainColors.surfaceHigh,
onSecondaryContainer: ChainColors.text,
tertiary: ChainColors.accent,
onTertiary: ChainColors.black,
tertiaryContainer: ChainColors.surfaceHigh,
onTertiaryContainer: ChainColors.text,
error: ChainColors.danger,
onError: ChainColors.textStrong,
errorContainer: const Color(0xFF3F1518),
onErrorContainer: const Color(0xFFFCA5A5),
surface: ChainColors.black,
onSurface: ChainColors.text,
onSurfaceVariant: ChainColors.muted,
outline: ChainColors.border,
outlineVariant: ChainColors.border,
surfaceContainerLowest: ChainColors.black,
surfaceContainerLow: ChainColors.surface,
surfaceContainer: ChainColors.surface,
surfaceContainerHigh: ChainColors.surfaceHigh,
surfaceContainerHighest: ChainColors.surfaceHigh,
);
return _build(scheme);
}
static ThemeData light() {
final scheme = ColorScheme(
brightness: Brightness.light,
// Sky-700, not sky-500: white button/label text on the lighter
// accent only reaches 2.8:1 — WCAG AA needs 4.5:1 (a11y_test.dart
// pins this). The dark theme keeps the lighter accent because it
// pairs it with near-black text.
primary: ChainColors.accentMuted,
onPrimary: ChainColors.lightSurface,
primaryContainer: const Color(0xFFE0F2FE),
onPrimaryContainer: ChainColors.accentMuted,
secondary: ChainColors.accentMuted,
onSecondary: ChainColors.lightSurface,
secondaryContainer: ChainColors.lightSurfaceHigh,
onSecondaryContainer: ChainColors.lightText,
tertiary: ChainColors.accentMuted,
onTertiary: ChainColors.lightSurface,
tertiaryContainer: const Color(0xFFE0F2FE),
onTertiaryContainer: ChainColors.accentMuted,
error: ChainColors.danger,
onError: ChainColors.lightSurface,
errorContainer: const Color(0xFFFEE2E2),
onErrorContainer: const Color(0xFF7F1D1D),
surface: ChainColors.lightCanvas,
onSurface: ChainColors.lightText,
onSurfaceVariant: ChainColors.lightMuted,
outline: ChainColors.lightBorder,
outlineVariant: ChainColors.lightBorder,
surfaceContainerLowest: ChainColors.lightCanvas,
surfaceContainerLow: ChainColors.lightSurface,
surfaceContainer: ChainColors.lightSurface,
surfaceContainerHigh: ChainColors.lightSurfaceHigh,
surfaceContainerHighest: ChainColors.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 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(
color: scheme.onSurface,
),
toolbarHeight: 64,
),
cardTheme: CardThemeData(
color: scheme.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ChainRadius.md),
side: BorderSide(color: scheme.outlineVariant),
),
margin: EdgeInsets.zero,
),
// Match FABs to the filled-button accent: Material 3 defaults
// them to primaryContainer, which reads as a washed-out tonal
// surface next to the sky-700 CTAs in the light theme.
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ChainRadius.sm),
),
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.lg,
vertical: ChainSpace.md,
),
textStyle: textTheme.labelMedium,
animationDuration: ChainMotion.fast,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.onSurface,
side: BorderSide(color: scheme.outline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ChainRadius.sm),
),
padding: const EdgeInsets.symmetric(
horizontal: ChainSpace.lg,
vertical: ChainSpace.md,
),
textStyle: textTheme.labelMedium,
animationDuration: ChainMotion.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(ChainRadius.sm),
),
),
);
}
}