0f05f47eef
Mirrors the web frontend's design-system foundation pass (shipped on web 2026-04-27 in 7a9a8b7) for the Flutter app. Single-file change to lib/core/theme.dart — most widgets read colorScheme.primary so the palette flip alone covers a large surface. Palette swap - dark: cool grey #0F0F14 / #16161F → Obsidian #14171A / Iron #1E2228 with Slate #2C313A and Pewter #3F4651 borders; Parchment #E8E4D8 primary text, Vellum #C2BFB4 secondary - light: white-and-cool-blue → warm parchment #F5F1E8 page / #FBF8F0 cards / #EFEAE0 inset; deep ink #14171A text; warm pewter #D9D6CE borders - primary (both modes): indigo #7C3AED → dusty violet #5B4A8A - error: indigo-red pair → terracotta #C04A1F (semantic Error per the doc; distinct from destructive Oxblood used for delete buttons) Action token ThemeExtension - New ActionColors extension exposes Moss / Bronze / Oxblood / Pewter outside the ColorScheme so widgets can read them via Theme.of(context).extension<ActionColors>()!.primary etc. - Hybrid rule: ColorScheme.primary stays the dusty-violet brand accent (Send buttons, empty-state CTAs); action buttons (Save / Cancel / Delete / ghost) use the ActionColors palette. No widget refactors in this commit — extension is defined for surface-phase work to consume. Typography - Inter loaded for body / labels / titleMedium-and-below (was system default) - Fraunces still loaded for display / headline / titleLarge — only at ≥18px per the doc rule - JetBrains Mono available for code at call sites via GoogleFonts.jetBrainsMono() (Flutter has no mono TextTheme slot) GradientButton - Brand-moment CTA equivalent of the web's --gradient-cta. Colors swapped from indigo gradient to dusty-violet (#5B4A8A → #3F3560); glow shadow rgba updated to match. Out of scope (deferred to later surface-phase work) - Per-screen button reclassification (Save → Moss FilledButton, Delete → Oxblood, etc.) — the ActionColors extension exists, no widgets reach for it yet - Lucide icon migration — separate task, requires picking a flutter_lucide / lucide_icons package - Input border radius (currently 24 / pill, doc says 8) — leave for surface phase - Voice/tone copy touchups - Chat bubble Illuminated Transcript pattern (assistant left-edge accent + glow) — not yet ported to ChatMessageBubble Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
361 lines
15 KiB
Dart
361 lines
15 KiB
Dart
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<ActionColors>()!.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<ActionColors> {
|
|
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<ActionColors>? 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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|