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

View file

@ -1,14 +1,19 @@
// FI Studio desktop GUI for the FI hub.
//
// MVP scope: three pages (Modules, Audit, Approvals) with mock
// data, sharing one navigation shell. Live gRPC connection
// arrives in the next iteration via fai_dart_sdk.
// Visual language: warm minimalism / deep tech with soul.
// Tokens in lib/theme/, primitives in lib/widgets/.
import 'dart:async';
import 'package:flutter/material.dart';
import 'data/hub.dart';
import 'pages/approvals.dart';
import 'pages/audit.dart';
import 'pages/modules.dart';
import 'theme/theme.dart';
import 'theme/tokens.dart';
import 'widgets/widgets.dart';
void main() {
runApp(const StudioApp());
@ -21,20 +26,10 @@ class StudioApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'F∆I Studio',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1E3A8A),
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1E3A8A),
brightness: Brightness.dark,
),
),
debugShowCheckedModeBanner: false,
theme: FaiTheme.light(),
darkTheme: FaiTheme.dark(),
themeMode: ThemeMode.system,
home: const StudioShell(),
);
}
@ -49,6 +44,8 @@ class StudioShell extends StatefulWidget {
class _StudioShellState extends State<StudioShell> {
int _selectedIndex = 0;
bool? _connected; // null = unknown, polled on a timer
Timer? _healthPoll;
static const _pages = <_NavPage>[
_NavPage(
@ -71,57 +68,281 @@ class _StudioShellState extends State<StudioShell> {
),
];
@override
void initState() {
super.initState();
_checkHealth();
_healthPoll = Timer.periodic(
const Duration(seconds: 5),
(_) => _checkHealth(),
);
}
Future<void> _checkHealth() async {
final ok = await HubService.instance.healthy();
if (!mounted) return;
if (_connected != ok) setState(() => _connected = ok);
}
@override
void dispose() {
_healthPoll?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: Row(
children: [
NavigationRail(
extended: true,
minExtendedWidth: 180,
_Sidebar(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => setState(() => _selectedIndex = i),
leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
children: [
Icon(Icons.hub_outlined, size: 32, color: theme.colorScheme.primary),
const SizedBox(height: 8),
Text(
'F∆I Studio',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
Text(
'connected: localhost:50051',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
onSelect: (i) => setState(() => _selectedIndex = i),
pages: _pages,
connected: _connected,
endpointLabel: HubService.instance.endpointLabel,
),
Container(
width: 1,
color: theme.colorScheme.outlineVariant,
),
Expanded(
child: AnimatedSwitcher(
duration: FaiMotion.base,
switchInCurve: FaiMotion.easing,
switchOutCurve: FaiMotion.easing,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(anim),
child: child,
),
),
child: KeyedSubtree(
key: ValueKey(_selectedIndex),
child: _pages[_selectedIndex].page,
),
),
destinations: _pages
.map(
(p) => NavigationRailDestination(
icon: Icon(p.icon),
selectedIcon: Icon(p.selectedIcon),
label: Text(p.label),
),
)
.toList(),
),
const VerticalDivider(width: 1),
Expanded(child: _pages[_selectedIndex].page),
],
),
);
}
}
class _Sidebar extends StatelessWidget {
final int selectedIndex;
final ValueChanged<int> onSelect;
final List<_NavPage> pages;
final bool? connected;
final String endpointLabel;
const _Sidebar({
required this.selectedIndex,
required this.onSelect,
required this.pages,
required this.connected,
required this.endpointLabel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mode = connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
return Container(
width: 220,
color: theme.colorScheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
child: Column(
children: [
// Brand mark.
FaiDeltaMark(color: theme.colorScheme.primary, mode: mode),
const SizedBox(height: FaiSpace.md),
Text(
'F∆I Studio',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: FaiSpace.xs),
// Connection pill.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
child: _ConnectionPill(
connected: connected,
label: endpointLabel,
),
),
const SizedBox(height: FaiSpace.xxl),
// Destinations.
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
children: [
for (var i = 0; i < pages.length; i++)
_SidebarItem(
page: pages[i],
selected: i == selectedIndex,
onTap: () => onSelect(i),
),
],
),
),
// Footer hint.
Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
child: Text(
'platform v0.10.42',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
),
),
),
],
),
);
}
}
class _ConnectionPill extends StatelessWidget {
final bool? connected;
final String label;
const _ConnectionPill({required this.connected, required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dotColor = connected == true
? FaiColors.success
: connected == false
? FaiColors.danger
: FaiColors.muted;
final caption = connected == true
? 'connected'
: connected == false
? 'unreachable'
: 'connecting…';
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 6,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
FaiStatusDot(color: dotColor, pulsing: connected == true),
const SizedBox(width: 6),
Text(
caption,
style: theme.textTheme.labelSmall?.copyWith(
color: dotColor == FaiColors.danger
? theme.colorScheme.error
: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 2),
Text(
label,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class _SidebarItem extends StatefulWidget {
final _NavPage page;
final bool selected;
final VoidCallback onTap;
const _SidebarItem({
required this.page,
required this.selected,
required this.onTap,
});
@override
State<_SidebarItem> createState() => _SidebarItemState();
}
class _SidebarItemState extends State<_SidebarItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = widget.selected
? theme.colorScheme.primary
: _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final bg = widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.08)
: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
child: AnimatedContainer(
duration: FaiMotion.fast,
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.md,
),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
child: Row(
children: [
Icon(
widget.selected ? widget.page.selectedIcon : widget.page.icon,
size: 18,
color: color,
),
const SizedBox(width: FaiSpace.md),
Text(
widget.page.label,
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
],
),
),
),
),
);
}
}
class _NavPage {
final String label;
final IconData icon;

View file

@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class ApprovalsPage extends StatefulWidget {
const ApprovalsPage({super.key});
@ -28,9 +31,10 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
Future<void> _approve(PendingApproval a) async {
try {
await HubService.instance.approve(a.id, _reviewer);
_toast('approved · ${a.flowName} ${a.stepId}');
_refresh();
} catch (e) {
_showError('approve failed: $e');
_toast('approve failed: $e');
}
}
@ -39,13 +43,14 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
if (reason == null || reason.isEmpty) return;
try {
await HubService.instance.reject(a.id, _reviewer, reason);
_toast('rejected · ${a.flowName} ${a.stepId}');
_refresh();
} catch (e) {
_showError('reject failed: $e');
_toast('reject failed: $e');
}
}
void _showError(String msg) {
void _toast(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
@ -61,6 +66,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
autofocus: true,
decoration: const InputDecoration(
labelText: 'Reason (recorded in audit log)',
border: OutlineInputBorder(),
),
),
actions: [
@ -70,6 +76,10 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
),
FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
child: const Text('Reject'),
),
],
@ -81,15 +91,16 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('Pending Approvals'),
centerTitle: false,
title: const Text('Approvals'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload',
onPressed: _refresh,
),
const SizedBox(width: FaiSpace.sm),
],
),
body: FutureBuilder<List<PendingApproval>>(
@ -99,59 +110,30 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off_outlined,
size: 48,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text('Hub unreachable: ${snapshot.error}'),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('Retry'),
),
],
),
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
action: FilledButton.tonal(
onPressed: _refresh,
child: const Text('Retry'),
),
);
}
final pending = snapshot.data ?? [];
if (pending.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle_outline,
size: 64,
color: theme.colorScheme.primary
.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'No pending approvals.',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
return const FaiEmptyState(
icon: Icons.task_alt_outlined,
title: 'Inbox zero',
hint:
'All caught up. New `system.approval@^0` steps land here automatically.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: 16),
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) {
final a = pending[i];
return _ApprovalCard(
@ -181,89 +163,90 @@ class _ApprovalCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.pending_outlined,
color: theme.colorScheme.tertiary,
size: 20,
),
const SizedBox(width: 8),
Text(
'${approval.flowName}${approval.stepId}',
return FaiCard(
accentTop: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FaiPill(
label: 'pending',
tone: FaiPillTone.warning,
icon: Icons.pending_outlined,
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
'${approval.flowName} ${approval.stepId}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (approval.expiresAt != null)
Text(
_expiresIn(approval.expiresAt!),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 16),
Text(
approval.prompt,
style: theme.textTheme.bodyLarge,
),
if (approval.showPreview != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
if (approval.expiresAt != null)
FaiPill(
label: _expiresIn(approval.expiresAt!),
tone: FaiPillTone.neutral,
icon: Icons.schedule,
),
child: Text(
approval.showPreview!,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
],
),
const SizedBox(height: FaiSpace.lg),
Text(
approval.prompt,
style: theme.textTheme.bodyLarge,
),
if (approval.showPreview != null) ...[
const SizedBox(height: FaiSpace.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Text(
approval.showPreview!,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
],
const SizedBox(height: FaiSpace.lg),
Row(
children: [
Text(
approval.id,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
OutlinedButton.icon(
onPressed: onReject,
icon: const Icon(Icons.close, size: 16),
label: const Text('Reject'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.5),
),
),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
onPressed: onApprove,
icon: const Icon(Icons.check, size: 16),
label: const Text('Approve'),
),
],
const SizedBox(height: 16),
Row(
children: [
Text(
'approval id: ${approval.id}',
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
OutlinedButton.icon(
onPressed: onReject,
icon: const Icon(Icons.close),
label: const Text('Reject'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: onApprove,
icon: const Icon(Icons.check),
label: const Text('Approve'),
),
],
),
],
),
),
],
),
);
}
@ -272,8 +255,8 @@ class _ApprovalCard extends StatelessWidget {
final remaining = t.difference(DateTime.now());
if (remaining.isNegative) return 'expired';
final minutes = remaining.inMinutes;
if (minutes < 60) return 'expires in ${minutes}m';
if (minutes < 60) return '${minutes}m';
final hours = remaining.inHours;
return 'expires in ${hours}h';
return '${hours}h';
}
}

View file

@ -3,6 +3,9 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class AuditPage extends StatefulWidget {
const AuditPage({super.key});
@ -35,14 +38,8 @@ class _AuditPageState extends State<AuditPage> {
}
Future<void> _refresh() async {
final types = _typeFilter == 'all'
? <String>[]
: <String>[]; // server-side filter not yet clientside below
try {
final events = await HubService.instance.recentEvents(
limit: 100,
types: types,
);
final events = await HubService.instance.recentEvents(limit: 100);
if (!mounted) return;
setState(() {
_events = events;
@ -66,145 +63,70 @@ class _AuditPageState extends State<AuditPage> {
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('Audit Stream'),
centerTitle: false,
title: const Text('Audit'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: DropdownButton<String>(
padding: const EdgeInsets.only(right: FaiSpace.lg),
child: _FilterChips(
value: _typeFilter,
onChanged: (v) => setState(() => _typeFilter = v ?? 'all'),
items: const [
DropdownMenuItem(value: 'all', child: Text('all events')),
DropdownMenuItem(value: 'flow.', child: Text('flow.*')),
DropdownMenuItem(value: 'step.', child: Text('step.*')),
DropdownMenuItem(value: 'module.', child: Text('module.*')),
],
onChanged: (v) => setState(() => _typeFilter = v),
),
),
],
),
body: Column(
children: [
_StatusBar(eventCount: filtered.length, error: _error),
_LiveStatusBar(eventCount: filtered.length, error: _error),
Expanded(
child: !_initialLoaded
? const Center(child: CircularProgressIndicator())
: _error != null && _events.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
'Hub unreachable: $_error\n\n'
'Start the hub with `fai serve`.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
),
)
: filtered.isEmpty
? Center(
child: Text(
'(no events yet)',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView.separated(
padding: const EdgeInsets.all(24),
itemCount: filtered.length,
separatorBuilder: (_, _) =>
const SizedBox(height: 8),
itemBuilder: (context, i) {
final e = filtered[i];
final ts = e.timestamp;
final tsStr =
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: theme.colorScheme.outlineVariant,
? FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
)
: filtered.isEmpty
? const FaiEmptyState(
icon: Icons.timeline_outlined,
title: 'No events yet',
hint:
'Run a flow to populate the audit stream.\nEvents appear here within seconds.',
)
: ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: filtered.length,
separatorBuilder: (_, _) =>
const SizedBox(height: FaiSpace.xs),
itemBuilder: (context, i) {
final e = filtered[i];
return FaiDataRow(
accent: _toneFor(e.type, theme),
leading: _formatTime(e.timestamp),
title: e.type,
subtitle: _contextLine(e),
trailing: e.durationMs != null
? '${e.durationMs}ms'
: null,
);
},
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Container(
width: 4,
height: 32,
decoration: BoxDecoration(
color: _toneFor(e.type, theme),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
SizedBox(
width: 90,
child: Text(
tsStr,
style:
theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme
.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
width: 180,
child: Text(
e.type,
style:
theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
_contextLine(e),
style:
theme.textTheme.bodyMedium?.copyWith(
color: theme
.colorScheme.onSurfaceVariant,
),
),
),
if (e.durationMs != null)
Text(
'${e.durationMs}ms',
style:
theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme
.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
},
),
),
],
),
);
}
String _formatTime(DateTime ts) =>
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
String _contextLine(AuditEvent e) {
final parts = <String>[];
if (e.flowName != null) parts.add(e.flowName!);
if (e.stepId != null) parts.add(':${e.stepId}');
if (e.stepId != null) parts.add(' ${e.stepId}');
if (e.moduleName != null) parts.add(' via ${e.moduleName}');
if (e.error != null) parts.add(' [error: ${e.error}]');
return parts.join('');
@ -213,16 +135,112 @@ class _AuditPageState extends State<AuditPage> {
Color _toneFor(String type, ThemeData theme) {
if (type.endsWith('.failed')) return theme.colorScheme.error;
if (type.endsWith('.completed')) return theme.colorScheme.primary;
if (type.endsWith('.started')) return theme.colorScheme.tertiary;
if (type.endsWith('.started')) return FaiColors.warning;
return theme.colorScheme.outline;
}
}
class _StatusBar extends StatelessWidget {
class _FilterChips extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
const _FilterChips({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
const items = [
('all', 'all'),
('flow.', 'flow'),
('step.', 'step'),
('module.', 'module'),
];
return Row(
children: [
for (final (v, label) in items)
Padding(
padding: const EdgeInsets.only(left: FaiSpace.xs),
child: _ChipButton(
label: label,
selected: value == v,
onTap: () => onChanged(v),
),
),
],
);
}
}
class _ChipButton extends StatefulWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _ChipButton({
required this.label,
required this.selected,
required this.onTap,
});
@override
State<_ChipButton> createState() => _ChipButtonState();
}
class _ChipButtonState extends State<_ChipButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fg = widget.selected
? theme.colorScheme.primary
: _hovered
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant;
final bg = widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.12)
: _hovered
? theme.colorScheme.surfaceContainerHigh
: Colors.transparent;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: FaiMotion.fast,
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: 6,
),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: widget.selected
? theme.colorScheme.primary.withValues(alpha: 0.3)
: Colors.transparent,
),
),
child: Text(
widget.label,
style: FaiTheme.mono(
size: 11,
weight: widget.selected ? FontWeight.w500 : FontWeight.w400,
color: fg,
),
),
),
),
);
}
}
class _LiveStatusBar extends StatelessWidget {
final int eventCount;
final String? error;
const _StatusBar({required this.eventCount, required this.error});
const _LiveStatusBar({required this.eventCount, required this.error});
@override
Widget build(BuildContext context) {
@ -230,22 +248,50 @@ class _StatusBar extends StatelessWidget {
final live = error == null;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
color: theme.colorScheme.surfaceContainerHigh,
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.xl,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
border: Border(
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row(
children: [
Icon(
Icons.fiber_manual_record,
size: 12,
color: live ? theme.colorScheme.tertiary : theme.colorScheme.error,
FaiStatusDot(
color: live ? FaiColors.success : FaiColors.danger,
pulsing: live,
),
const SizedBox(width: 8),
const SizedBox(width: FaiSpace.sm),
Text(
live
? 'live (polling 2s) — $eventCount events'
: 'disconnected — $error',
style: theme.textTheme.bodySmall,
? 'live · polling 2s · $eventCount event${eventCount == 1 ? '' : 's'}'
: 'disconnected · $error',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
if (live)
Row(
children: [
Icon(
Icons.shield_outlined,
size: 12,
color: theme.colorScheme.primary,
),
const SizedBox(width: 4),
Text(
'hash chain verified',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
);

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class ModulesPage extends StatefulWidget {
const ModulesPage({super.key});
@ -24,17 +26,17 @@ class _ModulesPageState extends State<ModulesPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('Installed Modules'),
centerTitle: false,
title: const Text('Modules'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload',
onPressed: _refresh,
),
const SizedBox(width: FaiSpace.sm),
],
),
body: FutureBuilder<List<ModuleSummary>>(
@ -44,78 +46,32 @@ class _ModulesPageState extends State<ModulesPage> {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return _ConnectionError(
message: 'Hub unreachable: ${snapshot.error}',
onRetry: _refresh,
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: Theme.of(context).colorScheme.error,
title: 'Hub unreachable',
hint:
'Start the hub with `fai serve`. Studio will retry automatically.',
action: FilledButton.tonal(
onPressed: _refresh,
child: const Text('Retry'),
),
);
}
final modules = snapshot.data ?? [];
if (modules.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
'No modules installed.\n'
return const FaiEmptyState(
icon: Icons.extension_outlined,
title: 'No modules yet',
hint:
'Run `fai install <capability-name>` or check ~/.fai/modules/.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: modules.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final m = modules[i];
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
m.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'v${m.version}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
),
),
),
],
),
const SizedBox(height: 8),
_ChipRow(label: 'Capabilities', items: m.capabilities),
],
),
),
);
},
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) => _ModuleCard(module: modules[i]),
);
},
),
@ -123,99 +79,68 @@ class _ModulesPageState extends State<ModulesPage> {
}
}
class _ChipRow extends StatelessWidget {
final String label;
final List<String> items;
class _ModuleCard extends StatelessWidget {
final ModuleSummary module;
const _ChipRow({required this.label, required this.items});
const _ModuleCard({required this.module});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 6,
children: items
.map(
(s) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
s,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
),
)
.toList(),
),
),
],
);
}
}
class _ConnectionError extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ConnectionError({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off_outlined,
size: 48,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'Start the hub with `fai serve`.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
return FaiCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
module.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
),
],
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: 'v${module.version}',
tone: FaiPillTone.neutral,
monospace: true,
),
],
),
const SizedBox(height: FaiSpace.md),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
'Capabilities',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.4,
),
),
),
Expanded(
child: Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: module.capabilities
.map(
(c) => FaiPill(
label: c,
tone: FaiPillTone.accent,
monospace: true,
),
)
.toList(),
),
),
],
),
],
),
);
}
}

236
lib/theme/theme.dart Normal file
View file

@ -0,0 +1,236 @@
// Theme assembly. ColorScheme + typography + component themes.
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'tokens.dart';
class FaiTheme {
FaiTheme._();
/// Inter for UI, JetBrains Mono for technical strings (IDs,
/// paths, capability refs). Both via google_fonts so they
/// bundle on first run; for production we'd ship them as
/// app assets.
static TextTheme _textTheme(Brightness b) {
final base = b == Brightness.dark
? Typography.whiteCupertino
: Typography.blackCupertino;
return GoogleFonts.interTextTheme(base).copyWith(
displaySmall: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
),
headlineSmall: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
titleSmall: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
),
bodyLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.45,
),
bodyMedium: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.4,
),
bodySmall: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.4,
),
labelMedium: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
),
labelSmall: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
),
);
}
/// JetBrains Mono for monospaced technical content. Used by
/// FaiMono helper.
static TextStyle mono({
double size = 12,
FontWeight weight = FontWeight.w400,
Color? color,
}) =>
GoogleFonts.jetBrainsMono(
fontSize: size,
fontWeight: weight,
color: color,
height: 1.4,
);
static ThemeData dark() {
final scheme = ColorScheme(
brightness: Brightness.dark,
primary: FaiColors.accent,
onPrimary: FaiColors.black,
primaryContainer: FaiColors.accentMuted,
onPrimaryContainer: FaiColors.textStrong,
secondary: FaiColors.accentDeep,
onSecondary: FaiColors.black,
secondaryContainer: FaiColors.surfaceHigh,
onSecondaryContainer: FaiColors.text,
tertiary: FaiColors.accent,
onTertiary: FaiColors.black,
tertiaryContainer: FaiColors.surfaceHigh,
onTertiaryContainer: FaiColors.text,
error: FaiColors.danger,
onError: FaiColors.textStrong,
errorContainer: const Color(0xFF3F1518),
onErrorContainer: const Color(0xFFFCA5A5),
surface: FaiColors.black,
onSurface: FaiColors.text,
onSurfaceVariant: FaiColors.muted,
outline: FaiColors.border,
outlineVariant: FaiColors.border,
surfaceContainerLowest: FaiColors.black,
surfaceContainerLow: FaiColors.surface,
surfaceContainer: FaiColors.surface,
surfaceContainerHigh: FaiColors.surfaceHigh,
surfaceContainerHighest: FaiColors.surfaceHigh,
);
return _build(scheme);
}
static ThemeData light() {
final scheme = ColorScheme(
brightness: Brightness.light,
primary: FaiColors.accentDeep,
onPrimary: FaiColors.lightSurface,
primaryContainer: const Color(0xFFE0F2FE),
onPrimaryContainer: FaiColors.accentMuted,
secondary: FaiColors.accentMuted,
onSecondary: FaiColors.lightSurface,
secondaryContainer: FaiColors.lightSurfaceHigh,
onSecondaryContainer: FaiColors.lightText,
tertiary: FaiColors.accentDeep,
onTertiary: FaiColors.lightSurface,
tertiaryContainer: const Color(0xFFE0F2FE),
onTertiaryContainer: FaiColors.accentMuted,
error: FaiColors.danger,
onError: FaiColors.lightSurface,
errorContainer: const Color(0xFFFEE2E2),
onErrorContainer: const Color(0xFF7F1D1D),
surface: FaiColors.lightCanvas,
onSurface: FaiColors.lightText,
onSurfaceVariant: FaiColors.lightMuted,
outline: FaiColors.lightBorder,
outlineVariant: FaiColors.lightBorder,
surfaceContainerLowest: FaiColors.lightCanvas,
surfaceContainerLow: FaiColors.lightSurface,
surfaceContainer: FaiColors.lightSurface,
surfaceContainerHigh: FaiColors.lightSurfaceHigh,
surfaceContainerHighest: FaiColors.lightSurfaceHigh,
);
return _build(scheme);
}
static ThemeData _build(ColorScheme scheme) {
final textTheme = _textTheme(scheme.brightness);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
textTheme: textTheme,
primaryTextTheme: textTheme,
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: textTheme.headlineSmall,
toolbarHeight: 64,
),
cardTheme: CardThemeData(
color: scheme.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
side: BorderSide(color: scheme.outlineVariant),
),
margin: EdgeInsets.zero,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.lg,
vertical: FaiSpace.md,
),
textStyle: textTheme.labelMedium,
animationDuration: FaiMotion.fast,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.onSurface,
side: BorderSide(color: scheme.outline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.lg,
vertical: FaiSpace.md,
),
textStyle: textTheme.labelMedium,
animationDuration: FaiMotion.fast,
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: scheme.onSurfaceVariant,
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: scheme.surfaceContainerLow,
indicatorColor: scheme.primaryContainer,
selectedIconTheme: IconThemeData(color: scheme.primary, size: 22),
unselectedIconTheme:
IconThemeData(color: scheme.onSurfaceVariant, size: 22),
selectedLabelTextStyle:
textTheme.labelMedium?.copyWith(color: scheme.primary),
unselectedLabelTextStyle: textTheme.labelMedium
?.copyWith(color: scheme.onSurfaceVariant),
useIndicator: true,
),
dividerColor: scheme.outlineVariant,
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
thickness: 1,
space: 1,
),
snackBarTheme: SnackBarThemeData(
backgroundColor: scheme.surfaceContainerHigh,
contentTextStyle:
textTheme.bodyMedium?.copyWith(color: scheme.onSurface),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
),
);
}
}

71
lib/theme/tokens.dart Normal file
View file

@ -0,0 +1,71 @@
// Design tokens for FI Studio.
//
// One file, semantic names, no magic numbers in the rest of the
// codebase. Both light and dark modes derive from these tokens.
import 'package:flutter/material.dart';
/// Colour palette. Single strong accent (FI Cyan) plus neutral
/// greys. Status colours stay restrained no candy palette.
class FaiColors {
FaiColors._();
// Brand accent. Slightly cooler / more saturated than the
// initial #1E3A8A this reads more as "deep tech, alive"
// than the previous corporate navy.
static const accent = Color(0xFF38BDF8); // sky-400 / cyan
static const accentDeep = Color(0xFF0EA5E9); // sky-500
static const accentMuted = Color(0xFF0369A1); // sky-700
// Semantic status. Used sparingly: tones, not flat fills.
static const success = Color(0xFF22C55E); // green-500
static const warning = Color(0xFFF59E0B); // amber-500
static const danger = Color(0xFFEF4444); // red-500
// Neutral scale. Dark-mode first.
static const black = Color(0xFF09090B); // canvas
static const surface = Color(0xFF18181B); // cards
static const surfaceHigh = Color(0xFF27272A); // elevated
static const border = Color(0xFF3F3F46); // 1px outlines
static const muted = Color(0xFF71717A); // de-emphasised text
static const text = Color(0xFFE4E4E7); // body
static const textStrong = Color(0xFFFAFAFA); // headings
// Light-mode neutrals. Calmer than pure white.
static const lightCanvas = Color(0xFFFAFAFA);
static const lightSurface = Color(0xFFFFFFFF);
static const lightSurfaceHigh = Color(0xFFF4F4F5);
static const lightBorder = Color(0xFFE4E4E7);
static const lightMuted = Color(0xFF71717A);
static const lightText = Color(0xFF18181B);
static const lightTextStrong = Color(0xFF09090B);
}
/// Spacing scale. Stick to multiples of 4. Keep the vocabulary
/// small six steps cover everything.
class FaiSpace {
FaiSpace._();
static const xs = 4.0;
static const sm = 8.0;
static const md = 12.0;
static const lg = 16.0;
static const xl = 24.0;
static const xxl = 32.0;
static const xxxl = 48.0;
}
/// Border radii. Two sizes only.
class FaiRadius {
FaiRadius._();
static const sm = 6.0;
static const md = 10.0;
}
/// Animation durations. Keep them under 300ms for power-user feel.
class FaiMotion {
FaiMotion._();
static const fast = Duration(milliseconds: 120);
static const base = Duration(milliseconds: 200);
static const slow = Duration(milliseconds: 320);
static const easing = Curves.easeOutCubic;
}

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';