feat(ui): warm-minimalism redesign — design tokens + 6 primitives + animated ∆

Replaces the Material-default look with a deliberate visual
language. Single accent (sky-cyan), Inter + JetBrains Mono via
google_fonts, dense rows over puffy cards, micro-interactions
under 250ms. Dark-first, light reaches parity.

New foundation under lib/theme/:
- tokens.dart   FaiColors / FaiSpace / FaiRadius / FaiMotion
- theme.dart    ColorScheme + typography + component themes for
                light and dark, plus FaiTheme.mono helper for
                technical strings (IDs, paths, capability refs).

Six primitives under lib/widgets/:
- FaiCard            flat card, optional accent stripe (top or
                     left). No shadows.
- FaiPill            small inline label with five tones
                     (neutral / accent / success / warning /
                     danger), optional mono and leading icon.
- FaiStatusDot       breathing dot, used as a "live" indicator.
- FaiDataRow         Linear-style dense row for the audit
                     stream — hover-elevation, mono leading,
                     coloured leading stripe.
- FaiEmptyState      gracious icon + title + hint + action,
                     replaces "(no data)" everywhere.
- FaiDeltaMark       the ∆ signature element. Three modes —
                     idle (still), live (gentle pulse), busy
                     (slow rotation). Drawn from primitives,
                     not a font glyph. Lives in the sidebar
                     header so the brand is always visible.

Page-level changes:
- main.dart        custom 220px sidebar replaces the Material
                   NavigationRail. ∆ on top, hub-connection
                   pill below it, hover-animated destinations,
                   page transitions are 200ms slide+fade.
- modules.dart     FaiCard rows with capability pills.
- audit.dart       FaiDataRow stream, segmented filter chips,
                   live status bar with hash-chain badge.
- approvals.dart   FaiCard with accent-top stripe, structured
                   action footer, toast on approve/reject,
                   themed reject-reason dialog.

google_fonts added as dep. flutter analyze clean. flutter test
2/2. flutter build macos --debug succeeds.

Bumps fai_studio 0.2.0 -> 0.3.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-05 21:49:37 +02:00
parent 127c497f73
commit c94504247f
23 changed files with 1951 additions and 468 deletions

83
lib/widgets/fai_card.dart Normal file
View file

@ -0,0 +1,83 @@
// FaiCard flat card with subtle accent gradient on the top
// border. Replaces Material's default Card for the F∆I look.
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
class FaiCard extends StatelessWidget {
/// Card body. Padding is applied internally; pass plain content.
final Widget child;
/// When true, paint a 2px accent-coloured stripe along the top
/// edge. Used by foreground / "this matters" cards (a pending
/// approval, a failed step).
final bool accentTop;
/// When true, paint a faint vertical accent stripe on the left.
/// Used by status rows (live event, healthy service).
final Color? accentLeft;
/// Internal padding. Defaults to [FaiSpace.lg].
final EdgeInsetsGeometry padding;
const FaiCard({
super.key,
required this.child,
this.accentTop = false,
this.accentLeft,
this.padding = const EdgeInsets.all(FaiSpace.lg),
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
if (accentTop)
Positioned(
top: 0,
left: 0,
right: 0,
height: 2,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withValues(alpha: 0.0),
theme.colorScheme.primary,
theme.colorScheme.primary.withValues(alpha: 0.0),
],
),
),
),
),
if (accentLeft != null)
Positioned(
top: FaiSpace.md,
bottom: FaiSpace.md,
left: 0,
width: 3,
child: DecoratedBox(
decoration: BoxDecoration(
color: accentLeft,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(2),
bottomRight: Radius.circular(2),
),
),
),
),
Padding(padding: padding, child: child),
],
),
);
}
}

View file

@ -0,0 +1,134 @@
// FaiDataRow Linear-style dense list row used by the audit
// stream. Hover-elevation, accent-coloured leading stripe, mono
// timestamp, expressive type badge.
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
class FaiDataRow extends StatefulWidget {
/// Coloured leading stripe (4px). Used to encode event type
/// (success / warning / failure) at a glance.
final Color accent;
/// Left-most column: a short, monospaced label (timestamp, id).
final String leading;
/// Title in the middle. Usually the event type, in mono.
final String title;
/// Subtitle on the right of title. Free-form, lighter colour.
final String subtitle;
/// Right-most column: trailing label (duration, etc).
final String? trailing;
/// Optional tap handler.
final VoidCallback? onTap;
const FaiDataRow({
super.key,
required this.accent,
required this.leading,
required this.title,
required this.subtitle,
this.trailing,
this.onTap,
});
@override
State<FaiDataRow> createState() => _FaiDataRowState();
}
class _FaiDataRowState extends State<FaiDataRow> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: widget.onTap != null
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: widget.onTap,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: FaiMotion.fast,
decoration: BoxDecoration(
color: _hovered
? theme.colorScheme.surfaceContainerHigh
: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: _hovered
? theme.colorScheme.outline
: theme.colorScheme.outlineVariant,
),
),
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
child: Row(
children: [
Container(
width: 3,
height: 24,
decoration: BoxDecoration(
color: widget.accent,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: FaiSpace.md),
SizedBox(
width: 88,
child: Text(
widget.leading,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
width: 200,
child: Text(
widget.title,
style: FaiTheme.mono(
size: 12,
weight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
),
Expanded(
child: Text(
widget.subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
if (widget.trailing != null)
Padding(
padding: const EdgeInsets.only(left: FaiSpace.md),
child: Text(
widget.trailing!,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,162 @@
// FaiDeltaMark the FI signature element. A precise triangle
// () drawn from primitives, not a font glyph. Three states:
//
// - idle: static, accent colour, soft glow
// - live: gentle pulse, like a heartbeat
// - busy: rotates slowly, used while a long operation runs
//
// Sized small. Lives in the navigation rail header so it's
// always visible and identifies the brand without screaming.
import 'dart:math';
import 'package:flutter/material.dart';
enum FaiDeltaMode { idle, live, busy }
class FaiDeltaMark extends StatefulWidget {
final FaiDeltaMode mode;
final double size;
final Color color;
const FaiDeltaMark({
super.key,
required this.color,
this.mode = FaiDeltaMode.idle,
this.size = 36,
});
@override
State<FaiDeltaMark> createState() => _FaiDeltaMarkState();
}
class _FaiDeltaMarkState extends State<FaiDeltaMark>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: _durationFor(widget.mode),
);
_restart();
}
Duration _durationFor(FaiDeltaMode m) {
switch (m) {
case FaiDeltaMode.idle:
return const Duration(seconds: 1);
case FaiDeltaMode.live:
return const Duration(milliseconds: 1600);
case FaiDeltaMode.busy:
return const Duration(seconds: 4);
}
}
void _restart() {
_ctrl.duration = _durationFor(widget.mode);
switch (widget.mode) {
case FaiDeltaMode.idle:
_ctrl.stop();
_ctrl.value = 0;
break;
case FaiDeltaMode.live:
_ctrl.repeat(reverse: true);
break;
case FaiDeltaMode.busy:
_ctrl.repeat();
break;
}
}
@override
void didUpdateWidget(covariant FaiDeltaMark old) {
super.didUpdateWidget(old);
if (old.mode != widget.mode) _restart();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (_, _) {
return CustomPaint(
size: Size.square(widget.size),
painter: _DeltaPainter(
color: widget.color,
mode: widget.mode,
t: _ctrl.value,
),
);
},
);
}
}
class _DeltaPainter extends CustomPainter {
final Color color;
final FaiDeltaMode mode;
final double t;
_DeltaPainter({required this.color, required this.mode, required this.t});
@override
void paint(Canvas canvas, Size size) {
final cx = size.width / 2;
final cy = size.height / 2;
final r = size.width * 0.42;
canvas.save();
canvas.translate(cx, cy);
if (mode == FaiDeltaMode.busy) {
canvas.rotate(t * 2 * pi);
}
// Outer triangle path.
final path = Path()
..moveTo(0, -r)
..lineTo(r * cos(pi / 6), r * sin(pi / 6))
..lineTo(-r * cos(pi / 6), r * sin(pi / 6))
..close();
// Glow halo for live / busy mode.
if (mode != FaiDeltaMode.idle) {
final pulseStrength = mode == FaiDeltaMode.live ? t : 1.0;
final glow = Paint()
..color = color.withValues(alpha: 0.25 + 0.25 * pulseStrength)
..maskFilter =
MaskFilter.blur(BlurStyle.normal, 6 + 6 * pulseStrength)
..style = PaintingStyle.fill;
canvas.drawPath(path, glow);
}
// Outline stroke.
final stroke = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round;
canvas.drawPath(path, stroke);
// Inner accent dot pulses opacity in live mode.
final dotAlpha = mode == FaiDeltaMode.live ? 0.4 + 0.6 * t : 1.0;
final dot = Paint()
..color = color.withValues(alpha: dotAlpha)
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(0, r * 0.15), r * 0.14, dot);
canvas.restore();
}
@override
bool shouldRepaint(covariant _DeltaPainter old) =>
old.t != t || old.mode != mode || old.color != color;
}

View file

@ -0,0 +1,75 @@
// FaiEmptyState gracious empty / loading / error placeholder.
// Replaces "(no data)" strings with one tone, one icon, one tip.
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
class FaiEmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String? hint;
final Widget? action;
/// Icon tone. Defaults to muted; set to [Theme.colorScheme.error]
/// for connection-failure variants.
final Color? iconColor;
const FaiEmptyState({
super.key,
required this.icon,
required this.title,
this.hint,
this.action,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = iconColor ?? theme.colorScheme.onSurfaceVariant;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(FaiSpace.xxl),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 24, color: color),
),
const SizedBox(height: FaiSpace.lg),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
if (hint != null) ...[
const SizedBox(height: FaiSpace.sm),
Text(
hint!,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (action != null) ...[
const SizedBox(height: FaiSpace.lg),
action!,
],
],
),
),
),
);
}
}

90
lib/widgets/fai_pill.dart Normal file
View file

@ -0,0 +1,90 @@
// FaiPill small inline label. Used for capabilities, versions,
// permission scopes, status text. Replaces ad-hoc Container chips.
import 'package:flutter/material.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
enum FaiPillTone {
neutral,
accent,
success,
warning,
danger,
}
class FaiPill extends StatelessWidget {
final String label;
final FaiPillTone tone;
final IconData? icon;
final bool monospace;
const FaiPill({
super.key,
required this.label,
this.tone = FaiPillTone.neutral,
this.icon,
this.monospace = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (bg, fg) = _colors(theme.colorScheme);
final textStyle = monospace
? FaiTheme.mono(size: 11, weight: FontWeight.w500, color: fg)
: theme.textTheme.labelSmall?.copyWith(color: fg);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 3,
),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 11, color: fg),
const SizedBox(width: 4),
],
Text(label, style: textStyle),
],
),
);
}
(Color, Color) _colors(ColorScheme scheme) {
switch (tone) {
case FaiPillTone.neutral:
return (scheme.surfaceContainerHighest, scheme.onSurfaceVariant);
case FaiPillTone.accent:
return (
scheme.primary.withValues(alpha: 0.15),
scheme.primary,
);
case FaiPillTone.success:
return (
FaiColors.success.withValues(alpha: 0.15),
scheme.brightness == Brightness.dark
? FaiColors.success
: const Color(0xFF15803D),
);
case FaiPillTone.warning:
return (
FaiColors.warning.withValues(alpha: 0.15),
scheme.brightness == Brightness.dark
? FaiColors.warning
: const Color(0xFFB45309),
);
case FaiPillTone.danger:
return (
scheme.errorContainer,
scheme.onErrorContainer,
);
}
}
}

View file

@ -0,0 +1,101 @@
// FaiStatusDot small pulsing dot used as a "live" indicator.
// Goes still when [pulsing] is false, breathes gently when true.
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
class FaiStatusDot extends StatefulWidget {
final Color color;
/// When true, the dot fades between full and 30% opacity.
/// When false, it stays at full opacity (steady-state).
final bool pulsing;
final double size;
const FaiStatusDot({
super.key,
required this.color,
this.pulsing = false,
this.size = 8,
});
@override
State<FaiStatusDot> createState() => _FaiStatusDotState();
}
class _FaiStatusDotState extends State<FaiStatusDot>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);
if (widget.pulsing) _ctrl.repeat(reverse: true);
}
@override
void didUpdateWidget(covariant FaiStatusDot old) {
super.didUpdateWidget(old);
if (widget.pulsing && !_ctrl.isAnimating) {
_ctrl.repeat(reverse: true);
} else if (!widget.pulsing && _ctrl.isAnimating) {
_ctrl.stop();
_ctrl.value = 0;
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (_, _) {
final t = widget.pulsing ? _ctrl.value : 0.0;
final alpha = 1.0 - (t * 0.6);
return Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.color.withValues(alpha: alpha),
shape: BoxShape.circle,
boxShadow: widget.pulsing
? [
BoxShadow(
color: widget.color.withValues(alpha: 0.4 * alpha),
blurRadius: 6 * (1 + t),
spreadRadius: 1 * t,
),
]
: const [],
),
);
},
);
}
}
/// Convenience presets.
class FaiStatusDots {
FaiStatusDots._();
static Widget live() => const FaiStatusDot(
color: FaiColors.success,
pulsing: true,
);
static Widget idle() => const FaiStatusDot(
color: FaiColors.muted,
);
static Widget down() => const FaiStatusDot(
color: FaiColors.danger,
);
}

12
lib/widgets/widgets.dart Normal file
View file

@ -0,0 +1,12 @@
// Barrel file for the FI Studio widget library.
//
// Pages should `import '../widgets/widgets.dart';` and use the
// primitives directly no Material `Card` / generic `Container`
// soup in page code.
export 'fai_card.dart';
export 'fai_data_row.dart';
export 'fai_delta_mark.dart';
export 'fai_empty_state.dart';
export 'fai_pill.dart';
export 'fai_status_dot.dart';