Merge pull request 'Release v26.04.28.1 — Design system foundation port' (#30) from dev into main

This commit was merged in pull request #30.
This commit is contained in:
2026-04-28 13:12:57 +00:00
+143 -29
View File
@@ -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),
),