feat(design): foundation port — dusty violet, warm parchment, action tokens
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>
This commit is contained in:
+143
-29
@@ -2,35 +2,142 @@ 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.
|
||||
|
||||
const _darkBackground = Color(0xFF0F0F14);
|
||||
const _darkSurface = Color(0xFF16161F);
|
||||
const _darkSurfaceVar = Color(0xFF1A1A24);
|
||||
const _darkPrimary = Color(0xFF7C3AED);
|
||||
const _darkOnSurface = Color(0xFFE4E4F0);
|
||||
const _darkOnSurfaceVar = Color(0xFF8888A8);
|
||||
const _darkOutline = Color(0xFF2A2A3A);
|
||||
// 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
|
||||
|
||||
const _lightBackground = Color(0xFFF5F5FB);
|
||||
const _lightSurface = Color(0xFFFFFFFF);
|
||||
const _lightSurfaceVar = Color(0xFFF0F0F8);
|
||||
const _lightPrimary = Color(0xFF7C3AED);
|
||||
const _lightOnSurface = Color(0xFF1A1A1A);
|
||||
const _lightOnSurfaceVar = Color(0xFF666666);
|
||||
const _lightOutline = Color(0xFFDDDDE8);
|
||||
// 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 base.copyWith(
|
||||
// Headings / titles use Fraunces
|
||||
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: fraunces.titleMedium,
|
||||
// Body / labels remain system default
|
||||
// titleMedium / titleSmall / body* / label* stay Inter
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +148,7 @@ ThemeData fabledDarkTheme() {
|
||||
brightness: Brightness.dark,
|
||||
primary: _darkPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFF5B21B6),
|
||||
primaryContainer: _darkPrimaryDeep,
|
||||
onPrimaryContainer: _darkOnSurface,
|
||||
secondary: _darkPrimary,
|
||||
onSecondary: Colors.white,
|
||||
@@ -51,10 +158,10 @@ ThemeData fabledDarkTheme() {
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _darkSurfaceVar,
|
||||
onTertiaryContainer: _darkOnSurface,
|
||||
error: const Color(0xFFEF4444),
|
||||
error: _semanticError,
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFF7F1D1D),
|
||||
onErrorContainer: const Color(0xFFFEE2E2),
|
||||
errorContainer: _semanticErrorBg,
|
||||
onErrorContainer: _semanticErrorFg,
|
||||
surface: _darkSurface,
|
||||
onSurface: _darkOnSurface,
|
||||
surfaceContainerHighest: _darkSurfaceVar,
|
||||
@@ -73,6 +180,7 @@ ThemeData fabledDarkTheme() {
|
||||
colorScheme: cs,
|
||||
scaffoldBackgroundColor: _darkBackground,
|
||||
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
||||
extensions: const [ActionColors._kStandard],
|
||||
cardTheme: CardThemeData(
|
||||
color: _darkSurface,
|
||||
elevation: 2,
|
||||
@@ -118,7 +226,9 @@ ThemeData fabledLightTheme() {
|
||||
brightness: Brightness.light,
|
||||
primary: _lightPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFFEDE5FF),
|
||||
// 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,
|
||||
@@ -128,10 +238,10 @@ ThemeData fabledLightTheme() {
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _lightSurfaceVar,
|
||||
onTertiaryContainer: _lightOnSurface,
|
||||
error: const Color(0xFFDC2626),
|
||||
error: _semanticError,
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFFFEE2E2),
|
||||
onErrorContainer: const Color(0xFF7F1D1D),
|
||||
onErrorContainer: _semanticErrorBg,
|
||||
surface: _lightSurface,
|
||||
onSurface: _lightOnSurface,
|
||||
surfaceContainerHighest: _lightSurfaceVar,
|
||||
@@ -150,6 +260,7 @@ ThemeData fabledLightTheme() {
|
||||
colorScheme: cs,
|
||||
scaffoldBackgroundColor: _lightBackground,
|
||||
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
||||
extensions: const [ActionColors._kStandard],
|
||||
cardTheme: CardThemeData(
|
||||
color: _lightSurface,
|
||||
elevation: 1,
|
||||
@@ -191,7 +302,10 @@ ThemeData fabledLightTheme() {
|
||||
}
|
||||
|
||||
// ── GradientButton ─────────────────────────────────────────────────────────────
|
||||
// Use wherever the web app uses the indigo gradient button (send, primary actions).
|
||||
// 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;
|
||||
@@ -218,15 +332,15 @@ class GradientButton extends StatelessWidget {
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
|
||||
colors: [_darkPrimary, _darkPrimaryDeep],
|
||||
),
|
||||
color: disabled ? const Color(0xFF7C3AED) : null,
|
||||
color: disabled ? _darkPrimary : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: disabled
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF7C3AED).withValues(alpha: 0.45),
|
||||
color: _darkPrimary.withValues(alpha: 0.45),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user