neoagent 2.4.1-beta.43 → 2.4.1-beta.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -639,6 +639,53 @@ class _PageTitle extends StatelessWidget {
639
639
  }
640
640
  }
641
641
 
642
+ IconData _runPhaseIcon(String phase) {
643
+ switch (phase.toLowerCase()) {
644
+ case 'thinking':
645
+ return Icons.psychology_outlined;
646
+ case 'analyzing':
647
+ return Icons.analytics_outlined;
648
+ case 'planning':
649
+ return Icons.list_alt_outlined;
650
+ case 'verifying':
651
+ return Icons.fact_check_outlined;
652
+ case 'streaming':
653
+ case 'responding':
654
+ return Icons.chat_outlined;
655
+ case 'awaiting approval':
656
+ return Icons.lock_clock_outlined;
657
+ case 'incorporating steering':
658
+ return Icons.tune_outlined;
659
+ case 'completed':
660
+ return Icons.check_circle_outline_rounded;
661
+ case 'stopped':
662
+ case 'stopping':
663
+ return Icons.stop_circle_outlined;
664
+ default:
665
+ return Icons.sync_outlined;
666
+ }
667
+ }
668
+
669
+ Color _runPhaseColor(String phase) {
670
+ switch (phase.toLowerCase()) {
671
+ case 'thinking':
672
+ case 'analyzing':
673
+ case 'planning':
674
+ return _info;
675
+ case 'verifying':
676
+ return _accentAlt;
677
+ case 'awaiting approval':
678
+ return _warning;
679
+ case 'completed':
680
+ return _success;
681
+ case 'stopped':
682
+ case 'stopping':
683
+ return _textSecondary;
684
+ default:
685
+ return _accent;
686
+ }
687
+ }
688
+
642
689
  class _RunStatusPanel extends StatelessWidget {
643
690
  const _RunStatusPanel({required this.run, required this.tools});
644
691
 
@@ -650,6 +697,9 @@ class _RunStatusPanel extends StatelessWidget {
650
697
  final runningCount = tools.where((tool) => tool.status == 'running').length;
651
698
  final helperCount = tools.where((tool) => tool.isHelperRelated).length;
652
699
  final webCount = tools.where((tool) => tool.isWebRelated).length;
700
+ final phase = run?.phase ?? '';
701
+ final phaseColor = _runPhaseColor(phase);
702
+ final isDeepRun = (run?.iteration ?? 0) >= 8;
653
703
 
654
704
  return Card(
655
705
  child: Padding(
@@ -659,6 +709,30 @@ class _RunStatusPanel extends StatelessWidget {
659
709
  children: <Widget>[
660
710
  Row(
661
711
  children: <Widget>[
712
+ if (phase.isNotEmpty) ...<Widget>[
713
+ _PulseHalo(
714
+ color: phaseColor,
715
+ animate: phase.toLowerCase() != 'completed' &&
716
+ phase.toLowerCase() != 'stopped',
717
+ child: Container(
718
+ width: 30,
719
+ height: 30,
720
+ decoration: BoxDecoration(
721
+ color: phaseColor.withValues(alpha: 0.14),
722
+ shape: BoxShape.circle,
723
+ border: Border.all(
724
+ color: phaseColor.withValues(alpha: 0.28),
725
+ ),
726
+ ),
727
+ child: Icon(
728
+ _runPhaseIcon(phase),
729
+ size: 16,
730
+ color: phaseColor,
731
+ ),
732
+ ),
733
+ ),
734
+ const SizedBox(width: 12),
735
+ ],
662
736
  Expanded(
663
737
  child: Column(
664
738
  crossAxisAlignment: CrossAxisAlignment.start,
@@ -670,7 +744,7 @@ class _RunStatusPanel extends StatelessWidget {
670
744
  fontWeight: FontWeight.w700,
671
745
  ),
672
746
  ),
673
- const SizedBox(height: 6),
747
+ const SizedBox(height: 4),
674
748
  Text(
675
749
  run == null
676
750
  ? 'Waiting for run events...'
@@ -679,7 +753,12 @@ class _RunStatusPanel extends StatelessWidget {
679
753
  if (run!.pendingSteeringCount > 0)
680
754
  '${run!.pendingSteeringCount} steering ${run!.pendingSteeringCount == 1 ? 'update' : 'updates'} queued',
681
755
  ].join(' · '),
682
- style: TextStyle(color: _textSecondary),
756
+ style: TextStyle(
757
+ color: phase.isNotEmpty
758
+ ? phaseColor.withValues(alpha: 0.8)
759
+ : _textSecondary,
760
+ fontSize: 12.5,
761
+ ),
683
762
  ),
684
763
  ],
685
764
  ),
@@ -714,6 +793,12 @@ class _RunStatusPanel extends StatelessWidget {
714
793
  label: '$helperCount helpers',
715
794
  icon: Icons.account_tree_outlined,
716
795
  ),
796
+ if (isDeepRun)
797
+ _MetaPill(
798
+ label: 'deep run · step ${run!.iteration}',
799
+ icon: Icons.warning_amber_outlined,
800
+ color: _warning,
801
+ ),
717
802
  ],
718
803
  ),
719
804
  const SizedBox(height: 14),
@@ -1479,10 +1564,15 @@ class _EmptyState extends StatelessWidget {
1479
1564
  }
1480
1565
 
1481
1566
  class _ChatBubble extends StatelessWidget {
1482
- const _ChatBubble({required this.entry, this.onLoadRunDetail});
1567
+ const _ChatBubble({
1568
+ required this.entry,
1569
+ this.onLoadRunDetail,
1570
+ this.onSendMessage,
1571
+ });
1483
1572
 
1484
1573
  final ChatEntry entry;
1485
1574
  final Future<RunDetailSnapshot> Function(String runId)? onLoadRunDetail;
1575
+ final void Function(String)? onSendMessage;
1486
1576
 
1487
1577
  @override
1488
1578
  Widget build(BuildContext context) {
@@ -1604,6 +1694,15 @@ class _ChatBubble extends StatelessWidget {
1604
1694
  onLoadRunDetail: onLoadRunDetail,
1605
1695
  ),
1606
1696
  ],
1697
+ if (!isUser &&
1698
+ entry.richPayload != null &&
1699
+ onSendMessage != null) ...<Widget>[
1700
+ const SizedBox(height: 12),
1701
+ _QuickReplyBar(
1702
+ payload: entry.richPayload!,
1703
+ onSelected: onSendMessage!,
1704
+ ),
1705
+ ],
1607
1706
  const SizedBox(height: 10),
1608
1707
  Text(
1609
1708
  entry.createdAtLabel,
@@ -3757,6 +3856,42 @@ String _summarizeToolResult(dynamic raw) {
3757
3856
  return text.length > 140 ? '${text.substring(0, 140)}…' : text;
3758
3857
  }
3759
3858
 
3859
+ class _QuickReplyBar extends StatelessWidget {
3860
+ const _QuickReplyBar({required this.payload, required this.onSelected});
3861
+
3862
+ final ChatRichPayload payload;
3863
+ final void Function(String value) onSelected;
3864
+
3865
+ @override
3866
+ Widget build(BuildContext context) {
3867
+ return Wrap(
3868
+ spacing: 8,
3869
+ runSpacing: 8,
3870
+ children: payload.options.map((option) {
3871
+ return GestureDetector(
3872
+ onTap: () => onSelected(option.value),
3873
+ child: Container(
3874
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
3875
+ decoration: BoxDecoration(
3876
+ color: _accentMuted,
3877
+ borderRadius: BorderRadius.circular(999),
3878
+ border: Border.all(color: _accent.withValues(alpha: 0.4)),
3879
+ ),
3880
+ child: Text(
3881
+ option.label,
3882
+ style: TextStyle(
3883
+ fontSize: 13,
3884
+ color: _accent,
3885
+ fontWeight: FontWeight.w600,
3886
+ ),
3887
+ ),
3888
+ ),
3889
+ );
3890
+ }).toList(growable: false),
3891
+ );
3892
+ }
3893
+ }
3894
+
3760
3895
  String _titleCase(String value) {
3761
3896
  final normalized = value.trim();
3762
3897
  if (normalized.isEmpty) {
@@ -2,7 +2,7 @@ part of 'main.dart';
2
2
 
3
3
  enum _ToolsPageTab { integrations, mcp, skills }
4
4
 
5
- enum _RunsPageTab { runs, logs }
5
+ enum _RunsPageTab { runs }
6
6
 
7
7
  enum _SettingsWorkspaceSection { app, account, security }
8
8
 
@@ -126,76 +126,7 @@ class RunsAndLogsPanel extends StatefulWidget {
126
126
  State<RunsAndLogsPanel> createState() => _RunsAndLogsPanelState();
127
127
  }
128
128
 
129
- class _RunsAndLogsPanelState extends State<RunsAndLogsPanel>
130
- with SingleTickerProviderStateMixin {
131
- late final TabController _tabController;
132
- bool _syncingFromController = false;
133
-
134
- @override
135
- void initState() {
136
- super.initState();
137
- _tabController = TabController(
138
- length: _RunsPageTab.values.length,
139
- vsync: this,
140
- initialIndex: _tabForSection(widget.controller.selectedSection).index,
141
- )..addListener(_handleTabChanged);
142
- }
143
-
144
- @override
145
- void didUpdateWidget(covariant RunsAndLogsPanel oldWidget) {
146
- super.didUpdateWidget(oldWidget);
147
- final selectedSection = widget.controller.selectedSection;
148
- if (selectedSection != oldWidget.controller.selectedSection &&
149
- (selectedSection == AppSection.runs ||
150
- selectedSection == AppSection.logs)) {
151
- final targetIndex = _tabForSection(selectedSection).index;
152
- if (_tabController.index != targetIndex) {
153
- _syncingFromController = true;
154
- _tabController.index = targetIndex;
155
- _syncingFromController = false;
156
- }
157
- }
158
- }
159
-
160
- @override
161
- void dispose() {
162
- _tabController.removeListener(_handleTabChanged);
163
- _tabController.dispose();
164
- super.dispose();
165
- }
166
-
167
- void _handleTabChanged() {
168
- if (_syncingFromController || _tabController.indexIsChanging) {
169
- return;
170
- }
171
- _selectSectionForTabIndex(_tabController.index);
172
- }
173
-
174
- void _selectSectionForTabIndex(int index) {
175
- final section = _sectionForTab(_RunsPageTab.values[index]);
176
- if (widget.controller.selectedSection != section) {
177
- widget.controller.setSelectedSection(section);
178
- }
179
- }
180
-
181
- _RunsPageTab _tabForSection(AppSection section) {
182
- switch (section) {
183
- case AppSection.logs:
184
- return _RunsPageTab.logs;
185
- default:
186
- return _RunsPageTab.runs;
187
- }
188
- }
189
-
190
- AppSection _sectionForTab(_RunsPageTab tab) {
191
- switch (tab) {
192
- case _RunsPageTab.logs:
193
- return AppSection.logs;
194
- case _RunsPageTab.runs:
195
- return AppSection.runs;
196
- }
197
- }
198
-
129
+ class _RunsAndLogsPanelState extends State<RunsAndLogsPanel> {
199
130
  @override
200
131
  Widget build(BuildContext context) {
201
132
  final controller = widget.controller;
@@ -204,38 +135,12 @@ class _RunsAndLogsPanelState extends State<RunsAndLogsPanel>
204
135
  child: Column(
205
136
  children: <Widget>[
206
137
  const _PageTitle(
207
- title: 'Runs & Logs',
208
- subtitle:
209
- 'Inspect execution history, failures, tool traces, and diagnostics from one workspace.',
210
- ),
211
- const SizedBox(height: 12),
212
- Container(
213
- decoration: BoxDecoration(
214
- color: _bgSecondary,
215
- borderRadius: BorderRadius.circular(14),
216
- border: Border.all(color: _border),
217
- ),
218
- child: TabBar(
219
- controller: _tabController,
220
- dividerColor: _border,
221
- indicatorSize: TabBarIndicatorSize.tab,
222
- labelStyle: const TextStyle(fontWeight: FontWeight.w700),
223
- onTap: _selectSectionForTabIndex,
224
- tabs: <Widget>[
225
- Tab(text: 'Runs (${controller.recentRuns.length})'),
226
- Tab(text: 'Logs (${controller.logs.length})'),
227
- ],
228
- ),
138
+ title: 'Runs',
139
+ subtitle: 'Inspect execution history, failures, and tool traces.',
229
140
  ),
230
141
  const SizedBox(height: 12),
231
142
  Expanded(
232
- child: TabBarView(
233
- controller: _tabController,
234
- children: <Widget>[
235
- RunsPanel(controller: controller, embedded: true),
236
- LogsPanel(controller: controller, embedded: true),
237
- ],
238
- ),
143
+ child: RunsPanel(controller: controller, embedded: true),
239
144
  ),
240
145
  ],
241
146
  ),
package/lib/manager.js CHANGED
@@ -222,6 +222,14 @@ function removeEnvValue(key) {
222
222
  return true;
223
223
  }
224
224
 
225
+ function readAdminCredentials() {
226
+ const env = Object.fromEntries(parseEnv(readEnvFileRaw()).entries());
227
+ return {
228
+ username: env.ADMIN_USERNAME || 'admin',
229
+ password: env.ADMIN_PASSWORD || '(not set — run `neoagent setup`)',
230
+ };
231
+ }
232
+
225
233
  function maskEnvValue(key, value) {
226
234
  if (!/(KEY|TOKEN|SECRET|PASSWORD)/i.test(key)) return value;
227
235
  const text = String(value || '');
@@ -768,6 +776,11 @@ async function cmdSetup() {
768
776
  origins ? `ALLOWED_ORIGINS=${origins}` : ''
769
777
  ].filter(Boolean);
770
778
 
779
+ const adminUsername = current.ADMIN_USERNAME || 'admin';
780
+ const adminPassword = current.ADMIN_PASSWORD || crypto.randomBytes(16).toString('hex');
781
+ lines.push(`ADMIN_USERNAME=${adminUsername}`);
782
+ lines.push(`ADMIN_PASSWORD=${adminPassword}`);
783
+
771
784
  fs.writeFileSync(ENV_FILE, `${lines.join('\n')}\n`, { mode: 0o600 });
772
785
  logOk(`Wrote ${ENV_FILE}`);
773
786
  }
@@ -1590,6 +1603,12 @@ async function cmdInstall() {
1590
1603
  printInstallActionItems();
1591
1604
  heading('Ready');
1592
1605
  logInfo(`Open http://localhost:${port} or run \`neoagent status\` for a health check.`);
1606
+ const adminCreds = readAdminCredentials();
1607
+ heading('Admin Dashboard');
1608
+ logOk(`URL: http://localhost:${port}/admin`);
1609
+ logInfo(`Username: ${adminCreds.username}`);
1610
+ logInfo(`Password: ${adminCreds.password}`);
1611
+ logWarn('Save these credentials — stored in .env and shown again with `neoagent admin`');
1593
1612
  }
1594
1613
 
1595
1614
  function cmdStart() {
@@ -1965,6 +1984,10 @@ function printHelp() {
1965
1984
  row('login grok-oauth', 'Authenticate Grok (xAI OAuth)');
1966
1985
  console.log('');
1967
1986
 
1987
+ cliSection('Admin');
1988
+ row('admin', 'Show admin dashboard URL and credentials');
1989
+ console.log('');
1990
+
1968
1991
  cliSection('Maintenance');
1969
1992
  row('migrate', 'Migrate from another agent installation');
1970
1993
  row('migrate dry-run', 'Preview what would be migrated');
@@ -2021,6 +2044,15 @@ async function runCLI(argv) {
2021
2044
  case 'login':
2022
2045
  await cmdLogin(argv.slice(1));
2023
2046
  break;
2047
+ case 'admin': {
2048
+ cliBanner('Admin Dashboard', 'credentials');
2049
+ const adminCreds = readAdminCredentials();
2050
+ const port = loadEnvPort();
2051
+ logOk(`URL: http://localhost:${port}/admin`);
2052
+ logInfo(`Username: ${adminCreds.username}`);
2053
+ logInfo(`Password: ${adminCreds.password}`);
2054
+ break;
2055
+ }
2024
2056
  case 'version':
2025
2057
  case '--version':
2026
2058
  case '-V':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.4.1-beta.43",
3
+ "version": "2.4.1-beta.45",
4
4
  "description": "Proactive personal AI agent with no limits",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
package/runtime/paths.js CHANGED
@@ -246,5 +246,7 @@ module.exports = {
246
246
  ensureRuntimeDirs,
247
247
  ensureSecureRuntimeEnv,
248
248
  getDefaultVmBaseImageUrl,
249
- migrateLegacyRuntime
249
+ migrateLegacyRuntime,
250
+ upsertEnvValue,
251
+ readEnvFileRaw,
250
252
  };
@@ -0,0 +1,268 @@
1
+ /* NeoAgent Admin — shared design tokens and base styles */
2
+
3
+ *, *::before, *::after {
4
+ box-sizing: border-box;
5
+ margin: 0;
6
+ padding: 0;
7
+ }
8
+
9
+ /* ── Tokens (NeoAgent dark palette) ─────────────────────────────────────── */
10
+ :root {
11
+ --bg: #0e1511;
12
+ --bg-sidebar: #0a0f0c;
13
+ --bg-card: #171f1a;
14
+ --bg-secondary: #252d28;
15
+ --bg-input: #0a0f0c;
16
+ --border: rgba(224, 240, 224, 0.10);
17
+ --border-light: rgba(224, 240, 224, 0.17);
18
+
19
+ --accent: #e1b052;
20
+ --accent-hover: #eac272;
21
+ --accent-alt: #84ba87;
22
+ --accent-muted: rgba(225, 176, 82, 0.16);
23
+
24
+ --text: #ecefe5;
25
+ --text-secondary:#aeb7a6;
26
+ --text-muted: #7e8877;
27
+
28
+ --success: #74c07c;
29
+ --warning: #d9a24b;
30
+ --danger: #de8a78;
31
+ --info: #6fb0a4;
32
+
33
+ --sidebar-w: 220px;
34
+ --radius: 10px;
35
+ --radius-sm: 7px;
36
+
37
+ --font-sans: 'Geist', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
38
+ --font-mono: 'Geist Mono', 'JetBrains Mono', 'Fira Code', 'Menlo', monospace;
39
+ }
40
+
41
+ /* ── Base ────────────────────────────────────────────────────────────────── */
42
+ html, body {
43
+ height: 100%;
44
+ }
45
+
46
+ body {
47
+ background: var(--bg);
48
+ color: var(--text);
49
+ font-family: var(--font-sans);
50
+ font-size: 14px;
51
+ line-height: 1.5;
52
+ -webkit-font-smoothing: antialiased;
53
+ -moz-osx-font-smoothing: grayscale;
54
+ }
55
+
56
+ /* ── Brand mark ──────────────────────────────────────────────────────────── */
57
+ .brand {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 10px;
61
+ text-decoration: none;
62
+ color: inherit;
63
+ }
64
+
65
+ .brand-mark {
66
+ width: 28px;
67
+ height: 28px;
68
+ border-radius: 8px;
69
+ flex-shrink: 0;
70
+ overflow: hidden;
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ }
75
+
76
+ .brand-mark img {
77
+ width: 28px;
78
+ height: 28px;
79
+ display: block;
80
+ }
81
+
82
+ .brand-name {
83
+ font-size: 15px;
84
+ font-weight: 700;
85
+ color: var(--text);
86
+ line-height: 1.1;
87
+ letter-spacing: -0.2px;
88
+ }
89
+
90
+ .brand-sub {
91
+ font-size: 11px;
92
+ color: var(--text-muted);
93
+ font-weight: 500;
94
+ letter-spacing: 0.02em;
95
+ text-transform: uppercase;
96
+ }
97
+
98
+ /* ── Forms ───────────────────────────────────────────────────────────────── */
99
+ label {
100
+ display: block;
101
+ font-size: 12px;
102
+ font-weight: 600;
103
+ letter-spacing: 0.04em;
104
+ color: var(--text-muted);
105
+ text-transform: uppercase;
106
+ margin-bottom: 7px;
107
+ }
108
+
109
+ input[type="text"],
110
+ input[type="password"],
111
+ select {
112
+ width: 100%;
113
+ background: var(--bg-input);
114
+ border: 1px solid var(--border-light);
115
+ border-radius: var(--radius-sm);
116
+ padding: 10px 14px;
117
+ color: var(--text);
118
+ font-family: var(--font-sans);
119
+ font-size: 14px;
120
+ outline: none;
121
+ transition: border-color 0.15s;
122
+ -webkit-appearance: none;
123
+ }
124
+
125
+ input[type="text"]:focus,
126
+ input[type="password"]:focus,
127
+ select:focus {
128
+ border-color: var(--accent);
129
+ box-shadow: 0 0 0 3px var(--accent-muted);
130
+ }
131
+
132
+ .field {
133
+ margin-bottom: 20px;
134
+ }
135
+
136
+ /* ── Buttons ─────────────────────────────────────────────────────────────── */
137
+ button,
138
+ .btn {
139
+ display: inline-flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ gap: 7px;
143
+ padding: 8px 16px;
144
+ border-radius: var(--radius-sm);
145
+ border: none;
146
+ cursor: pointer;
147
+ font-family: var(--font-sans);
148
+ font-size: 13px;
149
+ font-weight: 600;
150
+ transition: background 0.15s, opacity 0.15s, box-shadow 0.15s;
151
+ white-space: nowrap;
152
+ }
153
+
154
+ button:disabled,
155
+ .btn:disabled {
156
+ opacity: 0.4;
157
+ cursor: default;
158
+ pointer-events: none;
159
+ }
160
+
161
+ .btn-primary {
162
+ background: var(--accent);
163
+ color: var(--bg);
164
+ }
165
+
166
+ .btn-primary:hover:not(:disabled) {
167
+ background: var(--accent-hover);
168
+ box-shadow: 0 2px 12px rgba(225, 176, 82, 0.22);
169
+ }
170
+
171
+ .btn-ghost {
172
+ background: var(--bg-secondary);
173
+ color: var(--text-secondary);
174
+ border: 1px solid var(--border-light);
175
+ }
176
+
177
+ .btn-ghost:hover:not(:disabled) {
178
+ color: var(--text);
179
+ background: rgba(224, 240, 224, 0.07);
180
+ }
181
+
182
+ .btn-danger {
183
+ background: rgba(222, 138, 120, 0.12);
184
+ color: var(--danger);
185
+ border: 1px solid rgba(222, 138, 120, 0.20);
186
+ }
187
+
188
+ .btn-danger:hover:not(:disabled) {
189
+ background: rgba(222, 138, 120, 0.20);
190
+ }
191
+
192
+ /* ── Card ────────────────────────────────────────────────────────────────── */
193
+ .card {
194
+ background: var(--bg-card);
195
+ border: 1px solid var(--border);
196
+ border-radius: var(--radius);
197
+ padding: 20px;
198
+ margin-bottom: 16px;
199
+ }
200
+
201
+ .card-title {
202
+ font-size: 11px;
203
+ font-weight: 600;
204
+ text-transform: uppercase;
205
+ letter-spacing: 0.08em;
206
+ color: var(--text-muted);
207
+ margin-bottom: 14px;
208
+ }
209
+
210
+ /* ── Badges ──────────────────────────────────────────────────────────────── */
211
+ .badge {
212
+ display: inline-block;
213
+ padding: 2px 9px;
214
+ border-radius: 999px;
215
+ font-size: 11px;
216
+ font-weight: 600;
217
+ letter-spacing: 0.03em;
218
+ }
219
+
220
+ .badge-ok { background: rgba(116,192,124,0.15); color: var(--success); }
221
+ .badge-warn { background: rgba(217,162,75,0.15); color: var(--warning); }
222
+ .badge-err { background: rgba(222,138,120,0.15); color: var(--danger); }
223
+ .badge-idle { background: rgba(126,136,119,0.15); color: var(--text-muted); }
224
+ .badge-running { background: var(--accent-muted); color: var(--accent); }
225
+
226
+ /* ── Spinner ─────────────────────────────────────────────────────────────── */
227
+ @keyframes spin { to { transform: rotate(360deg); } }
228
+
229
+ .spinner {
230
+ display: inline-block;
231
+ width: 14px;
232
+ height: 14px;
233
+ border: 2px solid var(--border-light);
234
+ border-top-color: var(--accent);
235
+ border-radius: 50%;
236
+ animation: spin 0.65s linear infinite;
237
+ flex-shrink: 0;
238
+ }
239
+
240
+ /* ── Empty state ─────────────────────────────────────────────────────────── */
241
+ .empty {
242
+ padding: 28px;
243
+ text-align: center;
244
+ color: var(--text-muted);
245
+ font-size: 13px;
246
+ }
247
+
248
+ /* ── Alert ───────────────────────────────────────────────────────────────── */
249
+ .alert {
250
+ border-radius: var(--radius-sm);
251
+ padding: 10px 14px;
252
+ font-size: 13px;
253
+ display: none;
254
+ }
255
+
256
+ .alert.visible { display: block; }
257
+
258
+ .alert-error {
259
+ background: rgba(222, 138, 120, 0.10);
260
+ border: 1px solid rgba(222, 138, 120, 0.25);
261
+ color: var(--danger);
262
+ }
263
+
264
+ .alert-success {
265
+ background: rgba(116, 192, 124, 0.10);
266
+ border: 1px solid rgba(116, 192, 124, 0.25);
267
+ color: var(--success);
268
+ }