Files
tatlock-ui/docs/THEMING.md
T
jpmschweitzerandClaude Opus 4.5 0c3239f4b0 docs: add Phase 0 documentation
- docs/ARCHITECTURE.md - Clean Architecture patterns and conventions
- docs/API_INTEGRATION.md - Core API and Tatlock API endpoints
- docs/DEPLOYMENT.md - Docker, NPM, Portainer setup
- docs/DATAGRID.md - DataGrid component specification
- docs/THEMING.md - Material 3 theming guide

Also update seed color to teal for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 16:26:44 +01:00

445 lines
9.2 KiB
Markdown

# Theming Guide
This document describes the Material 3 theming system used in Tatlock UI.
## Overview
Tatlock UI uses Material 3 (Material You) with:
- Dynamic color schemes from a seed color
- System preference detection (light/dark)
- Manual override with persistence
- Consistent use of `colorScheme` throughout
## Theme Modes
### System Preference (Default)
By default, the app follows the system's light/dark mode preference:
```dart
MaterialApp(
themeMode: ThemeMode.system,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
)
```
### Manual Override
Users can override system preference. The choice persists via SharedPreferences:
```dart
enum ThemeSetting {
system, // Follow OS
light, // Always light
dark, // Always dark
}
```
## Color Scheme
### Seed Color
All colors derive from a single seed color for consistency:
```dart
const seedColor = Color(0xFF009688); // Teal
final lightScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.light,
);
final darkScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
);
```
### Using Colors
**Always use `colorScheme`** - never use raw colors in widgets:
```dart
// Good
Container(
color: Theme.of(context).colorScheme.surface,
child: Text(
'Hello',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
)
// Bad - don't do this
Container(
color: Colors.white, // Won't adapt to dark mode
child: Text('Hello', style: TextStyle(color: Colors.black)),
)
```
### ColorScheme Roles
| Role | Usage |
|------|-------|
| `primary` | Key actions, selected states, important text |
| `onPrimary` | Text/icons on primary backgrounds |
| `primaryContainer` | Less prominent primary surfaces |
| `secondary` | Secondary actions, accents |
| `surface` | Background of cards, sheets, dialogs |
| `onSurface` | Text on surface backgrounds |
| `surfaceContainerHighest` | Elevated surfaces (cards on surface) |
| `error` | Error states, destructive actions |
| `outline` | Borders, dividers |
| `outlineVariant` | Subtle borders |
### Semantic Colors
For domain-specific colors (status badges, charts), define semantic extensions:
```dart
extension SemanticColors on ColorScheme {
Color get success => brightness == Brightness.light
? const Color(0xFF2E7D32)
: const Color(0xFF81C784);
Color get warning => brightness == Brightness.light
? const Color(0xFFF57C00)
: const Color(0xFFFFB74D);
Color get info => brightness == Brightness.light
? const Color(0xFF1976D2)
: const Color(0xFF64B5F6);
}
// Usage
Container(color: Theme.of(context).colorScheme.success)
```
## Theme Data
### AppTheme Class
```dart
class AppTheme {
AppTheme._();
static const _seedColor = Color(0xFF009688); // Teal
static final light = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: Brightness.light,
),
// Component themes below
);
static final dark = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: Brightness.dark,
),
// Component themes below
);
}
```
### Component Themes
Customize individual components while maintaining consistency:
```dart
ThemeData(
// ...colorScheme...
appBarTheme: AppBarTheme(
centerTitle: false,
elevation: 0,
scrolledUnderElevation: 1,
),
cardTheme: CardTheme(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colorScheme.outlineVariant),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(88, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
)
```
## State Management
### ThemeNotifier
```dart
@riverpod
class ThemeNotifier extends _$ThemeNotifier {
static const _key = 'theme_setting';
@override
ThemeSetting build() {
_loadSavedSetting();
return ThemeSetting.system;
}
Future<void> _loadSavedSetting() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_key);
if (value != null) {
state = ThemeSetting.values.byName(value);
}
}
Future<void> setSetting(ThemeSetting setting) async {
state = setting;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, setting.name);
}
ThemeMode get themeMode => switch (state) {
ThemeSetting.system => ThemeMode.system,
ThemeSetting.light => ThemeMode.light,
ThemeSetting.dark => ThemeMode.dark,
};
}
```
### Using in App
```dart
class TatlockApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeNotifier = ref.watch(themeNotifierProvider.notifier);
return MaterialApp.router(
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: themeNotifier.themeMode,
routerConfig: router,
);
}
}
```
## Typography
### Text Styles
Use `textTheme` for consistent typography:
```dart
Text(
'Heading',
style: Theme.of(context).textTheme.headlineMedium,
)
Text(
'Body text',
style: Theme.of(context).textTheme.bodyLarge,
)
Text(
'Label',
style: Theme.of(context).textTheme.labelMedium,
)
```
### Text Theme Scale
| Style | Size | Weight | Usage |
|-------|------|--------|-------|
| `displayLarge` | 57 | 400 | Hero text |
| `displayMedium` | 45 | 400 | Large headers |
| `displaySmall` | 36 | 400 | Section headers |
| `headlineLarge` | 32 | 400 | Page titles |
| `headlineMedium` | 28 | 400 | Card titles |
| `headlineSmall` | 24 | 400 | Subheadings |
| `titleLarge` | 22 | 400 | App bar titles |
| `titleMedium` | 16 | 500 | List item titles |
| `titleSmall` | 14 | 500 | Tabs, chips |
| `bodyLarge` | 16 | 400 | Primary body text |
| `bodyMedium` | 14 | 400 | Secondary body text |
| `bodySmall` | 12 | 400 | Captions |
| `labelLarge` | 14 | 500 | Button text |
| `labelMedium` | 12 | 500 | Navigation labels |
| `labelSmall` | 11 | 500 | Badges, tags |
## Spacing
Use consistent spacing with a base unit:
```dart
class Spacing {
Spacing._();
static const double xs = 4;
static const double sm = 8;
static const double md = 16;
static const double lg = 24;
static const double xl = 32;
static const double xxl = 48;
}
// Usage
Padding(
padding: const EdgeInsets.all(Spacing.md),
child: ...
)
SizedBox(height: Spacing.sm)
```
## Border Radius
Consistent corner radii:
```dart
class Radii {
Radii._();
static const double sm = 4;
static const double md = 8;
static const double lg = 12;
static const double xl = 16;
static const double full = 9999;
}
// Usage
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.md),
),
)
```
## Elevation
Material 3 uses tonal elevation (surface tint) rather than shadows:
```dart
// Elevation levels
// 0 - Surface (no elevation)
// 1 - Slight elevation (cards, app bar scrolled)
// 2 - Moderate elevation (dialogs, menus)
// 3 - High elevation (navigation drawers)
Card(
elevation: 0, // Use tonal surface instead of shadow
color: Theme.of(context).colorScheme.surfaceContainerHighest,
)
```
## Dark Mode Considerations
### Contrast
Ensure sufficient contrast in dark mode:
- Primary text: `onSurface` (high contrast)
- Secondary text: `onSurfaceVariant` (medium contrast)
- Disabled text: `onSurface` with opacity
### Elevation in Dark Mode
Dark surfaces get lighter with elevation, not darker:
```dart
// M3 handles this automatically with surfaceContainerLow/High
```
### Images
Consider providing dark mode variants for images/icons:
```dart
Image.asset(
Theme.of(context).brightness == Brightness.dark
? 'assets/logo_dark.png'
: 'assets/logo_light.png',
)
```
## Testing Themes
### Widget Tests
```dart
testWidgets('renders correctly in dark mode', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark,
home: const MyWidget(),
),
);
// Verify dark mode appearance
});
```
### Golden Tests
Use golden tests to catch visual regressions:
```dart
testWidgets('matches golden in light mode', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: const MyWidget(),
),
);
await expectLater(
find.byType(MyWidget),
matchesGoldenFile('goldens/my_widget_light.png'),
);
});
```
## flex_color_scheme (Optional)
For more sophisticated theming, consider `flex_color_scheme`:
```dart
import 'package:flex_color_scheme/flex_color_scheme.dart';
final lightTheme = FlexThemeData.light(
scheme: FlexScheme.teal,
surfaceMode: FlexSurfaceMode.levelSurfacesLowScaffold,
blendLevel: 9,
subThemesData: const FlexSubThemesData(
blendOnLevel: 10,
blendOnColors: false,
),
useMaterial3: true,
);
final darkTheme = FlexThemeData.dark(
scheme: FlexScheme.teal,
surfaceMode: FlexSurfaceMode.levelSurfacesLowScaffold,
blendLevel: 15,
subThemesData: const FlexSubThemesData(
blendOnLevel: 20,
),
useMaterial3: true,
);
```