import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; // ── Colour constants ────────────────────────────────────────────────────────── // // Mirrors the web frontend's design system (`docs/design-system.md` in // fabledscribe). Foundation pass shipped on web 2026-04-27 in `7a9a8b7`; // this is the equivalent palette swap for Flutter. Per-screen "surface // phase" reclassification (button Hybrid rule, border audit, etc.) is // deferred — most widgets read `colorScheme.primary` so the palette flip // alone covers a large surface. // Dark mode — Obsidian / Iron / Pewter / Parchment (FabledSword baseline) const _darkBackground = Color(0xFF14171A); // Obsidian const _darkSurface = Color(0xFF1E2228); // Iron const _darkSurfaceVar = Color(0xFF2C313A); // Slate const _darkPrimary = Color(0xFF5B4A8A); // dusty violet (Scribe accent) const _darkPrimaryDeep = Color(0xFF3F3560); // gradient stop const _darkOnSurface = Color(0xFFE8E4D8); // Parchment const _darkOnSurfaceVar = Color(0xFFC2BFB4); // Vellum const _darkOutline = Color(0xFF3F4651); // Pewter // Light mode — warm parchment (Scribe iteration) const _lightBackground = Color(0xFFF5F1E8); // warm cream page const _lightSurface = Color(0xFFFBF8F0); // near-white card const _lightSurfaceVar = Color(0xFFEFEAE0); // inset / hover surface const _lightPrimary = Color(0xFF5B4A8A); // dusty violet (same on both modes) const _lightOnSurface = Color(0xFF14171A); // deep ink (Obsidian inverted) const _lightOnSurfaceVar = Color(0xFF5A5852); // warm mid grey const _lightOutline = Color(0xFFD9D6CE); // warm light pewter // Semantic — identical across themes const _semanticError = Color(0xFFC04A1F); // terracotta — validation/error const _semanticErrorBg = Color(0xFF7E2A1F); // Oxblood-hover for dark error container const _semanticErrorFg = Color(0xFFFEE2E2); // light text on dark error container // Action tokens — Hybrid rule per the doc. These don't fit ColorScheme // natively (Material's primary/secondary/tertiary slots all carry the // brand accent), so they live on a ThemeExtension exposed below. const _actionPrimary = Color(0xFF4A5D3F); // Moss const _actionPrimaryHover = Color(0xFF5A6F4D); const _actionSecondary = Color(0xFF8B7355); // Bronze const _actionSecondaryHover = Color(0xFFA0876A); const _actionDestructive = Color(0xFF6B2118); // Oxblood const _actionDestructiveHover = Color(0xFF7E2A1F); const _actionGhostBorder = Color(0xFF3F4651); // Pewter (same as outline) // ── Action ThemeExtension ───────────────────────────────────────────────────── // Read with: Theme.of(context).extension()!.primary // // Flutter's ColorScheme has primary/secondary/tertiary all conceptually // "branded", whereas the doc's Hybrid rule reserves accent for brand // moments (Send, empty-state CTAs) and routes action buttons through a // separate Moss/Bronze/Oxblood/Pewter palette. This extension carries // those tokens without polluting ColorScheme. @immutable class ActionColors extends ThemeExtension { final Color primary; // Moss — Save / Confirm final Color primaryHover; final Color secondary; // Bronze — Cancel / alternate paths final Color secondaryHover; final Color destructive; // Oxblood — Delete / irreversible final Color destructiveHover; final Color ghostBorder; // Pewter — tertiary / "later" / "skip" const ActionColors({ required this.primary, required this.primaryHover, required this.secondary, required this.secondaryHover, required this.destructive, required this.destructiveHover, required this.ghostBorder, }); static const _kStandard = ActionColors( primary: _actionPrimary, primaryHover: _actionPrimaryHover, secondary: _actionSecondary, secondaryHover: _actionSecondaryHover, destructive: _actionDestructive, destructiveHover: _actionDestructiveHover, ghostBorder: _actionGhostBorder, ); @override ActionColors copyWith({ Color? primary, Color? primaryHover, Color? secondary, Color? secondaryHover, Color? destructive, Color? destructiveHover, Color? ghostBorder, }) { return ActionColors( primary: primary ?? this.primary, primaryHover: primaryHover ?? this.primaryHover, secondary: secondary ?? this.secondary, secondaryHover: secondaryHover ?? this.secondaryHover, destructive: destructive ?? this.destructive, destructiveHover: destructiveHover ?? this.destructiveHover, ghostBorder: ghostBorder ?? this.ghostBorder, ); } @override ActionColors lerp(ThemeExtension? other, double t) { if (other is! ActionColors) return this; return ActionColors( primary: Color.lerp(primary, other.primary, t)!, primaryHover: Color.lerp(primaryHover, other.primaryHover, t)!, secondary: Color.lerp(secondary, other.secondary, t)!, secondaryHover: Color.lerp(secondaryHover, other.secondaryHover, t)!, destructive: Color.lerp(destructive, other.destructive, t)!, destructiveHover: Color.lerp(destructiveHover, other.destructiveHover, t)!, ghostBorder: Color.lerp(ghostBorder, other.ghostBorder, t)!, ); } } // ── Typography ───────────────────────────────────────────────────────────────── // Inter for body / labels / titleMedium-and-below. // Fraunces for display / headline / titleLarge — only at ≥18px per the doc. // JetBrains Mono available via GoogleFonts.jetBrainsMono() at call sites for // code blocks (Flutter's TextTheme has no dedicated mono slot). TextTheme _buildTextTheme(TextTheme base) { final inter = GoogleFonts.interTextTheme(base); final fraunces = GoogleFonts.frauncesTextTheme(base); return inter.copyWith( displayLarge: fraunces.displayLarge, displayMedium: fraunces.displayMedium, displaySmall: fraunces.displaySmall, headlineLarge: fraunces.headlineLarge, headlineMedium: fraunces.headlineMedium, headlineSmall: fraunces.headlineSmall, titleLarge: fraunces.titleLarge, // titleMedium / titleSmall / body* / label* stay Inter ); } // ── Themes ───────────────────────────────────────────────────────────────────── ThemeData fabledDarkTheme() { final cs = ColorScheme( brightness: Brightness.dark, primary: _darkPrimary, onPrimary: Colors.white, primaryContainer: _darkPrimaryDeep, onPrimaryContainer: _darkOnSurface, secondary: _darkPrimary, onSecondary: Colors.white, secondaryContainer: _darkSurfaceVar, onSecondaryContainer: _darkOnSurface, tertiary: _darkPrimary, onTertiary: Colors.white, tertiaryContainer: _darkSurfaceVar, onTertiaryContainer: _darkOnSurface, error: _semanticError, onError: Colors.white, errorContainer: _semanticErrorBg, onErrorContainer: _semanticErrorFg, surface: _darkSurface, onSurface: _darkOnSurface, surfaceContainerHighest: _darkSurfaceVar, onSurfaceVariant: _darkOnSurfaceVar, outline: _darkOutline, outlineVariant: _darkOutline, shadow: Colors.black, scrim: Colors.black, inverseSurface: _darkOnSurface, onInverseSurface: _darkSurface, inversePrimary: _lightPrimary, ); return ThemeData( useMaterial3: true, colorScheme: cs, scaffoldBackgroundColor: _darkBackground, textTheme: _buildTextTheme(ThemeData.dark().textTheme), extensions: const [ActionColors._kStandard], cardTheme: CardThemeData( color: _darkSurface, elevation: 2, shadowColor: Colors.black.withValues(alpha: 0.4), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), ), navigationBarTheme: NavigationBarThemeData( backgroundColor: _darkSurface, indicatorColor: _darkPrimary.withValues(alpha: 0.2), ), navigationRailTheme: NavigationRailThemeData( backgroundColor: _darkSurface, indicatorColor: _darkPrimary.withValues(alpha: 0.2), ), inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: _darkSurfaceVar, border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide(color: _darkOutline), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide(color: _darkOutline), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide(color: _darkPrimary, width: 2), ), ), dividerTheme: DividerThemeData(color: _darkOutline, thickness: 1), chipTheme: ChipThemeData( backgroundColor: _darkSurfaceVar, labelStyle: TextStyle(color: _darkOnSurfaceVar, fontSize: 12), side: BorderSide(color: _darkOutline), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), ); } ThemeData fabledLightTheme() { final cs = ColorScheme( brightness: Brightness.light, primary: _lightPrimary, onPrimary: Colors.white, // Warm parchment-tinted primary container, replacing the prior cool // indigo `#EDE5FF`. Used for chip/badge backgrounds at low alpha. primaryContainer: const Color(0xFFEDE9F4), onPrimaryContainer: _lightOnSurface, secondary: _lightPrimary, onSecondary: Colors.white, secondaryContainer: _lightSurfaceVar, onSecondaryContainer: _lightOnSurface, tertiary: _lightPrimary, onTertiary: Colors.white, tertiaryContainer: _lightSurfaceVar, onTertiaryContainer: _lightOnSurface, error: _semanticError, onError: Colors.white, errorContainer: const Color(0xFFFEE2E2), onErrorContainer: _semanticErrorBg, surface: _lightSurface, onSurface: _lightOnSurface, surfaceContainerHighest: _lightSurfaceVar, onSurfaceVariant: _lightOnSurfaceVar, outline: _lightOutline, outlineVariant: _lightOutline, shadow: Colors.black, scrim: Colors.black, inverseSurface: _lightOnSurface, onInverseSurface: _lightSurface, inversePrimary: _darkPrimary, ); return ThemeData( useMaterial3: true, colorScheme: cs, scaffoldBackgroundColor: _lightBackground, textTheme: _buildTextTheme(ThemeData.light().textTheme), extensions: const [ActionColors._kStandard], cardTheme: CardThemeData( color: _lightSurface, elevation: 1, shadowColor: Colors.black.withValues(alpha: 0.08), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), ), navigationBarTheme: NavigationBarThemeData( backgroundColor: _lightSurface, indicatorColor: _lightPrimary.withValues(alpha: 0.12), ), navigationRailTheme: NavigationRailThemeData( backgroundColor: _lightSurface, indicatorColor: _lightPrimary.withValues(alpha: 0.12), ), inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: _lightSurfaceVar, border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide(color: _lightOutline), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide(color: _lightOutline), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide(color: _lightPrimary, width: 2), ), ), dividerTheme: DividerThemeData(color: _lightOutline, thickness: 1), chipTheme: ChipThemeData( backgroundColor: _lightSurfaceVar, labelStyle: TextStyle(color: _lightOnSurfaceVar, fontSize: 12), side: BorderSide(color: _lightOutline), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), ); } // ── GradientButton ───────────────────────────────────────────────────────────── // Brand-moment CTA equivalent of the web's `--gradient-cta` — chat send, // journal send, primary "Scribe-feature" actions. Reserve for those moments // per the doc's Hybrid rule; use FilledButton with ActionColors.primary // (Moss) for everything else. class GradientButton extends StatelessWidget { final VoidCallback? onPressed; final Widget child; final EdgeInsetsGeometry padding; const GradientButton({ super.key, required this.onPressed, required this.child, this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 12), }); @override Widget build(BuildContext context) { final disabled = onPressed == null; return AnimatedOpacity( opacity: disabled ? 0.45 : 1.0, duration: const Duration(milliseconds: 150), child: DecoratedBox( decoration: BoxDecoration( gradient: disabled ? null : const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [_darkPrimary, _darkPrimaryDeep], ), color: disabled ? _darkPrimary : null, borderRadius: BorderRadius.circular(12), boxShadow: disabled ? null : [ BoxShadow( color: _darkPrimary.withValues(alpha: 0.45), blurRadius: 8, offset: const Offset(0, 3), ), ], ), child: Material( color: Colors.transparent, child: InkWell( onTap: onPressed, borderRadius: BorderRadius.circular(12), child: Padding(padding: padding, child: child), ), ), ), ); } }