@raingor/pi-web-switch 0.4.3 → 0.4.4

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.
@@ -472,6 +472,7 @@ export function SessionsPage() {
472
472
  const [trash, setTrash] = useState<TrashEntry[]>([]);
473
473
  const [loading, setLoading] = useState(true);
474
474
  const [refreshing, setRefreshing] = useState(false);
475
+ const [autoTrashed, setAutoTrashed] = useState(0);
475
476
  const [error, setError] = useState<string | null>(null);
476
477
  const [filter, setFilter] = useState("");
477
478
  const [deleteTarget, setDeleteTarget] = useState<{ session: SessionInfo; groupPath: string } | null>(null);
@@ -490,10 +491,16 @@ export function SessionsPage() {
490
491
  const loadAll = useCallback(() => {
491
492
  if (!initialized) return;
492
493
  setRefreshing(true);
493
- Promise.all([
494
- fetch("/api/pi/sessions").then((r) => r.json()),
495
- fetch("/api/pi/trash").then((r) => r.json()),
496
- ])
494
+ fetch("/api/pi/sessions/auto-trash", { method: "POST" })
495
+ .then((r) => r.json())
496
+ .catch(() => ({ moved: 0 }))
497
+ .then((cleanup) => {
498
+ setAutoTrashed(Number(cleanup?.moved) || 0);
499
+ return Promise.all([
500
+ fetch("/api/pi/sessions").then((r) => r.json()),
501
+ fetch("/api/pi/trash").then((r) => r.json()),
502
+ ]);
503
+ })
497
504
  .then(([sessionData, trashData]) => {
498
505
  setGroups(sessionData);
499
506
  setTrash(trashData);
@@ -704,6 +711,13 @@ function countExpandableNodes(nodes: TreeNode[]): number {
704
711
  </div>
705
712
  </div>
706
713
 
714
+ {autoTrashed > 0 && (
715
+ <div className="flex items-center gap-2 rounded-lg border px-3 py-2 text-xs" style={{ borderColor: "color-mix(in srgb, var(--signal-cyan) 30%, var(--card-border))", color: "var(--signal-cyan)", backgroundColor: "var(--accent-bg)" }}>
716
+ <Trash2 className="h-3.5 w-3.5" />
717
+ {t("sessions.auto_trashed", String(autoTrashed))}
718
+ </div>
719
+ )}
720
+
707
721
  {/* Tabs: Sessions / Trash */}
708
722
  <div className="flex items-center gap-1 border-b" style={{ borderColor: "var(--card-border)" }}>
709
723
  {([
@@ -200,7 +200,11 @@ export function SettingsPage() {
200
200
 
201
201
  // Apply + persist UI zoom (whole-interface percentage scaling).
202
202
  useEffect(() => {
203
- document.documentElement.style.zoom = `${uiZoom}%`;
203
+ // Migrate away from the old browser zoom implementation. Keeping the
204
+ // legacy `zoom` property would compound scaling with the new layout-aware
205
+ // transform and make dense pages appear clipped or unexpectedly tiny.
206
+ document.documentElement.style.zoom = "";
207
+ document.documentElement.style.setProperty("--ui-zoom", String(uiZoom / 100));
204
208
  localStorage.setItem(UI_ZOOM_KEY, String(uiZoom));
205
209
  }, [uiZoom]);
206
210
 
@@ -427,6 +431,7 @@ export function SettingsPage() {
427
431
  max={200}
428
432
  step={5}
429
433
  value={uiZoom}
434
+ onInput={(e) => setUiZoom(Number(e.currentTarget.value))}
430
435
  onChange={(e) => setUiZoom(Number(e.target.value))}
431
436
  className="flex-1 accent-blue-500"
432
437
  />
@@ -0,0 +1,429 @@
1
+ import { useMemo, useState, useEffect } from "react";
2
+ import { Gauge, Loader2, Zap, Check, X, RotateCcw, Download } from "lucide-react";
3
+ import { useTranslation } from "@/lib/i18n";
4
+ import { useConfigStore } from "@/store/config-store";
5
+ import { cn } from "@/lib/utils";
6
+ import type { Provider } from "@/types";
7
+
8
+ // Model returned by /api/pi/provider-models. Kept local to this page — these
9
+ // are stored separately from the provider's configured/enabled models.
10
+ interface FetchedModel {
11
+ id: string;
12
+ name?: string;
13
+ contextWindow?: number;
14
+ maxTokens?: number;
15
+ reasoning?: boolean;
16
+ vision?: boolean;
17
+ audio?: boolean;
18
+ cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
19
+ source?: string;
20
+ }
21
+
22
+ // Per-model speed-test result.
23
+ interface ModelResult {
24
+ status: "idle" | "testing" | "done";
25
+ runs: number;
26
+ success: number;
27
+ latencies: number[];
28
+ lastMessage?: string;
29
+ }
30
+
31
+ const RUNS_PER_MODEL = 3; // sequential calls per model to derive a rate
32
+ // Two speed profiles. Slow mode spaces requests out and retries harder to
33
+ // avoid tripping upstream rate limits (HTTP 429).
34
+ const SPEED_PROFILES = {
35
+ normal: { betweenCalls: 600, betweenModels: 800, maxRetries: 2, backoff: 3000 },
36
+ slow: { betweenCalls: 2000, betweenModels: 4000, maxRetries: 4, backoff: 6000 },
37
+ } as const;
38
+ type SpeedMode = keyof typeof SPEED_PROFILES;
39
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
40
+ // LocalStorage key: the speed-test model catalog is kept entirely separate
41
+ // from the app's configured models (models.json / enabledModels).
42
+ const STORE_KEY = "speedtest:model-catalog";
43
+
44
+ function avg(nums: number[]): number {
45
+ if (nums.length === 0) return 0;
46
+ return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length);
47
+ }
48
+
49
+ // Load/save the fetched-model catalog (map of providerId → FetchedModel[]).
50
+ function loadCatalog(): Record<string, FetchedModel[]> {
51
+ try {
52
+ const raw = localStorage.getItem(STORE_KEY);
53
+ return raw ? JSON.parse(raw) : {};
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+ function saveCatalog(catalog: Record<string, FetchedModel[]>) {
59
+ try {
60
+ localStorage.setItem(STORE_KEY, JSON.stringify(catalog));
61
+ } catch {
62
+ /* ignore quota errors */
63
+ }
64
+ }
65
+
66
+ export function ModelSpeedTestPage() {
67
+ const { t } = useTranslation();
68
+ const { allProviders, auth } = useConfigStore();
69
+
70
+ // Resolve a usable API key for a provider (models.json apiKey or auth.json).
71
+ const keyOf = (p: Provider) => p.apiKey ?? auth?.[p.id]?.key ?? "";
72
+
73
+ // Custom providers only (built-in providers are excluded from speed tests).
74
+ // A provider is listed once it has an endpoint — models come from the
75
+ // locally-stored speed-test catalog, not the configured model list.
76
+ const testableProviders = useMemo(
77
+ () => allProviders.filter((p) => p.type === "custom" && (p.baseUrl ?? "").trim() !== ""),
78
+ [allProviders]
79
+ );
80
+
81
+ const [selectedId, setSelectedId] = useState<string | null>(
82
+ testableProviders[0]?.id ?? null
83
+ );
84
+ const selected = testableProviders.find((p) => p.id === selectedId) ?? testableProviders[0] ?? null;
85
+
86
+ // Speed-test model catalog, persisted in localStorage.
87
+ const [catalog, setCatalog] = useState<Record<string, FetchedModel[]>>({});
88
+ useEffect(() => { setCatalog(loadCatalog()); }, []);
89
+ const models = selected ? (catalog[selected.id] ?? []) : [];
90
+
91
+ const [results, setResults] = useState<Map<string, ModelResult>>(new Map());
92
+ const [running, setRunning] = useState(false);
93
+ const [speedMode, setSpeedMode] = useState<SpeedMode>("normal");
94
+ const [fetching, setFetching] = useState(false);
95
+ const [fetchError, setFetchError] = useState<string | null>(null);
96
+ const [fetchInfo, setFetchInfo] = useState<string | null>(null);
97
+
98
+ const setResult = (modelId: string, patch: Partial<ModelResult>) => {
99
+ setResults((prev) => {
100
+ const next = new Map(prev);
101
+ const cur = next.get(modelId) ?? { status: "idle", runs: 0, success: 0, latencies: [] };
102
+ next.set(modelId, { ...cur, ...patch });
103
+ return next;
104
+ });
105
+ };
106
+
107
+ // One-click: fetch all models from the provider endpoint, store locally.
108
+ const fetchModels = async () => {
109
+ if (!selected || fetching) return;
110
+ setFetching(true);
111
+ setFetchError(null);
112
+ setFetchInfo(null);
113
+ try {
114
+ const res = await fetch("/api/pi/provider-models", {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({
118
+ baseUrl: (selected.baseUrl ?? "").trim(),
119
+ apiKey: keyOf(selected),
120
+ providerId: selected.id,
121
+ }),
122
+ });
123
+ const data = await res.json();
124
+ if (data.error) {
125
+ setFetchError(data.error);
126
+ return;
127
+ }
128
+ const fetched: FetchedModel[] = data.models ?? [];
129
+ const next = { ...loadCatalog(), [selected.id]: fetched };
130
+ saveCatalog(next);
131
+ setCatalog(next);
132
+ setResults(new Map()); // stale results no longer match the new list
133
+ setFetchInfo(t("speed_test.fetched_count", String(fetched.length)));
134
+ } catch {
135
+ setFetchError(t("speed_test.fetch_failed"));
136
+ } finally {
137
+ setFetching(false);
138
+ }
139
+ };
140
+
141
+ const clearModels = () => {
142
+ if (!selected || fetching || running) return;
143
+ const next = { ...loadCatalog() };
144
+ delete next[selected.id];
145
+ saveCatalog(next);
146
+ setCatalog(next);
147
+ setResults(new Map());
148
+ setFetchInfo(null);
149
+ };
150
+
151
+ const testModelOnce = async (provider: Provider, model: FetchedModel) => {
152
+ const res = await fetch("/api/pi/model-test", {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json" },
155
+ body: JSON.stringify({
156
+ baseUrl: (provider.baseUrl ?? "").trim(),
157
+ modelId: model.id,
158
+ apiKey: keyOf(provider),
159
+ apiType: provider.api ?? undefined,
160
+ }),
161
+ });
162
+ return res.json() as Promise<{ success: boolean; latencyMs?: number; message?: string; status?: number }>;
163
+ };
164
+
165
+ // Returns true when a result indicates a rate-limit response.
166
+ const isRateLimited = (r: { status?: number; message?: string }) =>
167
+ r.status === 429 || /\b429\b|rate.?limit|too many/i.test(r.message ?? "");
168
+
169
+ const testOneModel = async (provider: Provider, model: FetchedModel) => {
170
+ const profile = SPEED_PROFILES[speedMode];
171
+ setResults((prev) => {
172
+ const next = new Map(prev);
173
+ next.set(model.id, { status: "testing", runs: 0, success: 0, latencies: [] });
174
+ return next;
175
+ });
176
+ let success = 0;
177
+ const latencies: number[] = [];
178
+ let lastMessage: string | undefined;
179
+ for (let i = 0; i < RUNS_PER_MODEL; i++) {
180
+ if (i > 0) await sleep(profile.betweenCalls);
181
+ let data: { success: boolean; latencyMs?: number; message?: string; status?: number };
182
+ let attempt = 0;
183
+ // Retry with backoff specifically on 429 so bursty limits recover.
184
+ // eslint-disable-next-line no-constant-condition
185
+ while (true) {
186
+ try {
187
+ data = await testModelOnce(provider, model);
188
+ } catch {
189
+ data = { success: false, message: "network error" };
190
+ }
191
+ if (data.success || !isRateLimited(data) || attempt >= profile.maxRetries) break;
192
+ attempt++;
193
+ await sleep(profile.backoff * attempt);
194
+ }
195
+ if (data!.success) {
196
+ success++;
197
+ if (typeof data!.latencyMs === "number") latencies.push(data!.latencyMs);
198
+ } else {
199
+ lastMessage = data!.message;
200
+ }
201
+ setResult(model.id, { status: "testing", runs: i + 1, success, latencies, lastMessage });
202
+ }
203
+ setResult(model.id, { status: "done", runs: RUNS_PER_MODEL, success, latencies, lastMessage });
204
+ };
205
+
206
+ const runAll = async () => {
207
+ if (!selected || running || models.length === 0) return;
208
+ const profile = SPEED_PROFILES[speedMode];
209
+ setRunning(true);
210
+ setResults(new Map());
211
+ try {
212
+ for (let i = 0; i < models.length; i++) {
213
+ if (i > 0) await sleep(profile.betweenModels);
214
+ const model = models[i];
215
+ if (model) await testOneModel(selected, model);
216
+ }
217
+ } finally {
218
+ setRunning(false);
219
+ }
220
+ };
221
+
222
+ const resetResults = () => {
223
+ if (running) return;
224
+ setResults(new Map());
225
+ };
226
+
227
+ return (
228
+ <div className="space-y-6 providers-page">
229
+ <div className="providers-command-header">
230
+ <div>
231
+ <div className="page-kicker"><span /> MODEL BENCHMARK // LATENCY MATRIX</div>
232
+ <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>
233
+ {t("speed_test.title")}
234
+ </h1>
235
+ <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
236
+ {t("speed_test.subtitle")}
237
+ </p>
238
+ </div>
239
+ <div className="providers-header-signal"><span /> {t("speed_test.runs_note", String(RUNS_PER_MODEL))}</div>
240
+ </div>
241
+
242
+ {testableProviders.length === 0 ? (
243
+ <div className="tech-panel flex flex-col items-center gap-2 rounded-xl p-10 text-center">
244
+ <Gauge className="h-8 w-8" style={{ color: "var(--muted-text)" }} />
245
+ <h2 className="text-lg font-semibold" style={{ color: "var(--page-text)" }}>{t("speed_test.no_provider")}</h2>
246
+ <p className="text-sm" style={{ color: "var(--muted-text)" }}>{t("speed_test.no_provider_desc")}</p>
247
+ </div>
248
+ ) : (
249
+ <div className="providers-console flex overflow-hidden rounded-xl border border-gray-800 bg-gray-900/50">
250
+ {/* Left: provider picker */}
251
+ <div className="provider-rail w-60 shrink-0 border-r border-gray-800 p-3">
252
+ <p className="px-2 pb-2 pt-1 text-xs font-medium uppercase tracking-wider text-gray-500">
253
+ {t("speed_test.providers")} ({testableProviders.length})
254
+ </p>
255
+ <div className="space-y-0.5">
256
+ {testableProviders.map((p) => {
257
+ const count = (catalog[p.id] ?? []).length;
258
+ return (
259
+ <button
260
+ key={p.id}
261
+ disabled={running || fetching}
262
+ onClick={() => setSelectedId(p.id)}
263
+ className={cn(
264
+ "flex w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors disabled:opacity-50",
265
+ selected?.id === p.id
266
+ ? "border-blue-500 bg-blue-500/10 text-white"
267
+ : "border-transparent text-gray-300 hover:bg-gray-800 hover:text-white"
268
+ )}
269
+ >
270
+ <span className="min-w-0 flex-1 truncate">{p.name}</span>
271
+ <span className="shrink-0 rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-[10px] font-mono text-gray-400">
272
+ {count}
273
+ </span>
274
+ </button>
275
+ );
276
+ })}
277
+ </div>
278
+ </div>
279
+
280
+ {/* Right: fetch + speed results */}
281
+ <div className="flex-1 space-y-4 p-4">
282
+ {selected && (
283
+ <>
284
+ <div className="flex flex-wrap items-center justify-between gap-3">
285
+ <div>
286
+ <h2 className="text-lg font-semibold text-white">{selected.name}</h2>
287
+ <p className="text-xs text-gray-500">{models.length} {t("speed_test.models")}</p>
288
+ </div>
289
+ <div className="flex flex-wrap items-center gap-2">
290
+ <label className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-2.5 py-2 text-xs text-gray-300">
291
+ <input
292
+ type="checkbox"
293
+ checked={speedMode === "slow"}
294
+ disabled={running || fetching}
295
+ onChange={(e) => setSpeedMode(e.target.checked ? "slow" : "normal")}
296
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
297
+ />
298
+ {t("speed_test.slow_mode")}
299
+ </label>
300
+ <button
301
+ onClick={fetchModels}
302
+ disabled={fetching || running}
303
+ className="flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
304
+ >
305
+ {fetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
306
+ {fetching ? t("speed_test.fetching") : t("speed_test.fetch_models")}
307
+ </button>
308
+ <button
309
+ onClick={resetResults}
310
+ disabled={running || fetching || results.size === 0}
311
+ className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
312
+ >
313
+ <RotateCcw className="h-4 w-4" />
314
+ {t("speed_test.reset")}
315
+ </button>
316
+ <button
317
+ onClick={runAll}
318
+ disabled={running || fetching || models.length === 0}
319
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
320
+ style={{ backgroundColor: "#3b82f6" }}
321
+ >
322
+ {running ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
323
+ {running ? t("speed_test.testing") : t("speed_test.run_all")}
324
+ </button>
325
+ </div>
326
+ </div>
327
+
328
+ {fetchError && (
329
+ <div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">
330
+ <X className="mt-0.5 h-4 w-4 shrink-0" />{fetchError}
331
+ </div>
332
+ )}
333
+ {fetchInfo && !fetchError && (
334
+ <div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm text-emerald-300">
335
+ <Check className="h-4 w-4 shrink-0" />{fetchInfo}
336
+ <button onClick={clearModels} className="ml-auto text-xs text-gray-400 underline hover:text-gray-200">
337
+ {t("speed_test.clear")}
338
+ </button>
339
+ </div>
340
+ )}
341
+
342
+ {models.length === 0 ? (
343
+ <div className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-gray-700 p-10 text-center">
344
+ <Download className="h-7 w-7 text-gray-600" />
345
+ <p className="text-sm text-gray-400">{t("speed_test.empty_catalog")}</p>
346
+ <p className="text-xs text-gray-600">{t("speed_test.empty_catalog_desc")}</p>
347
+ </div>
348
+ ) : (
349
+ <div className="overflow-hidden rounded-lg border border-gray-800">
350
+ <table className="w-full text-sm">
351
+ <thead>
352
+ <tr className="border-b border-gray-800 text-left text-xs uppercase tracking-wider text-gray-500">
353
+ <th className="px-3 py-2 font-medium">{t("speed_test.col_model")}</th>
354
+ <th className="px-3 py-2 font-medium text-right">{t("speed_test.col_success_rate")}</th>
355
+ <th className="px-3 py-2 font-medium text-right">{t("speed_test.col_avg_latency")}</th>
356
+ <th className="px-3 py-2 font-medium text-right">{t("speed_test.col_range")}</th>
357
+ <th className="px-3 py-2 font-medium">{t("speed_test.col_status")}</th>
358
+ </tr>
359
+ </thead>
360
+ <tbody>
361
+ {models.map((m) => {
362
+ const r = results.get(m.id);
363
+ const rate = r && r.runs > 0 ? Math.round((r.success / r.runs) * 100) : null;
364
+ const avgMs = r ? avg(r.latencies) : 0;
365
+ const minMs = r && r.latencies.length ? Math.min(...r.latencies) : 0;
366
+ const maxMs = r && r.latencies.length ? Math.max(...r.latencies) : 0;
367
+ return (
368
+ <tr key={m.id} className="border-b border-gray-800/60 last:border-0">
369
+ <td className="px-3 py-2">
370
+ <span className="font-mono text-gray-200">{m.id}</span>
371
+ </td>
372
+ <td className="px-3 py-2 text-right">
373
+ {rate === null ? (
374
+ <span className="text-gray-600">—</span>
375
+ ) : (
376
+ <span className={cn(
377
+ "font-mono",
378
+ rate >= 100 ? "text-emerald-400" : rate > 0 ? "text-amber-400" : "text-red-400"
379
+ )}>
380
+ {rate}% ({r!.success}/{r!.runs})
381
+ </span>
382
+ )}
383
+ </td>
384
+ <td className="px-3 py-2 text-right font-mono text-gray-300">
385
+ {avgMs > 0 ? `${avgMs} ms` : <span className="text-gray-600">—</span>}
386
+ </td>
387
+ <td className="px-3 py-2 text-right font-mono text-xs text-gray-500">
388
+ {minMs > 0 ? `${minMs}–${maxMs}` : "—"}
389
+ </td>
390
+ <td className="px-3 py-2">
391
+ {!r || r.status === "idle" ? (
392
+ <span className="text-xs text-gray-600">{t("speed_test.pending")}</span>
393
+ ) : r.status === "testing" ? (
394
+ <span className="flex items-center gap-1 text-xs text-gray-400">
395
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
396
+ {t("speed_test.testing")} {r.runs}/{RUNS_PER_MODEL}
397
+ </span>
398
+ ) : r.success === r.runs ? (
399
+ <span className="flex items-center gap-1 text-xs text-emerald-400">
400
+ <Check className="h-3.5 w-3.5" />
401
+ {t("speed_test.ok")}
402
+ </span>
403
+ ) : r.success > 0 ? (
404
+ <span className="flex items-center gap-1 text-xs text-amber-400">
405
+ <Check className="h-3.5 w-3.5" />
406
+ {t("speed_test.partial")}
407
+ </span>
408
+ ) : (
409
+ <span className="flex items-center gap-1 text-xs text-red-400" title={r.lastMessage}>
410
+ <X className="h-3.5 w-3.5" />
411
+ {r.lastMessage ? r.lastMessage.slice(0, 40) : t("speed_test.fail")}
412
+ </span>
413
+ )}
414
+ </td>
415
+ </tr>
416
+ );
417
+ })}
418
+ </tbody>
419
+ </table>
420
+ </div>
421
+ )}
422
+ </>
423
+ )}
424
+ </div>
425
+ </div>
426
+ )}
427
+ </div>
428
+ );
429
+ }
@@ -10,11 +10,12 @@ interface EmptyStateProps {
10
10
 
11
11
  export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
12
12
  return (
13
- <div className={cn("flex flex-col items-center justify-center rounded-xl border border-dashed border-gray-700 p-12 text-center", className)}>
14
- <div className="mb-4 text-gray-500">{icon}</div>
15
- <h3 className="text-lg font-medium text-gray-300">{title}</h3>
16
- {description && <p className="mt-1 text-sm text-gray-500">{description}</p>}
17
- {action && <div className="mt-4">{action}</div>}
13
+ <div className={cn("empty-signal", className)}>
14
+ <div className="empty-signal-radar"><span>{icon}</span></div>
15
+ <p className="empty-signal-code">NO SIGNAL // STANDBY</p>
16
+ <h3>{title}</h3>
17
+ {description && <p>{description}</p>}
18
+ {action && <div className="mt-5">{action}</div>}
18
19
  </div>
19
20
  );
20
- }
21
+ }
@@ -1,41 +1,49 @@
1
- import type { ReactNode } from "react";
1
+ import { X } from "lucide-react";
2
+ import { useEffect, type ReactNode } from "react";
3
+ import { createPortal } from "react-dom";
2
4
 
3
5
  interface ModalProps {
4
6
  open: boolean;
5
7
  onClose: () => void;
6
8
  title: string;
7
9
  children: ReactNode;
8
- size?: "sm" | "md" | "lg";
10
+ size?: "sm" | "md" | "lg" | "xl";
9
11
  }
10
12
 
11
13
  export function Modal({ open, onClose, title, children, size = "md" }: ModalProps) {
14
+ // Close on Escape for keyboard accessibility.
15
+ useEffect(() => {
16
+ if (!open) return;
17
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
18
+ window.addEventListener("keydown", onKey);
19
+ return () => window.removeEventListener("keydown", onKey);
20
+ }, [open, onClose]);
21
+
12
22
  if (!open) return null;
13
23
 
14
- const sizeClasses = {
15
- sm: "max-w-md",
16
- md: "max-w-lg",
17
- lg: "max-w-2xl",
18
- };
24
+ const sizeClasses = { sm: "max-w-md", md: "max-w-lg", lg: "max-w-2xl", xl: "max-w-4xl" };
19
25
 
20
- return (
21
- <div className="fixed inset-0 z-50 flex items-center justify-center">
22
- <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
23
- <div
24
- className={`relative w-full ${sizeClasses[size]} mx-4 rounded-xl border border-gray-700 bg-gray-900 shadow-2xl`}
25
- >
26
- <div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
27
- <h2 className="text-lg font-semibold text-white">{title}</h2>
28
- <button
29
- onClick={onClose}
30
- className="rounded-lg p-1 text-gray-400 hover:bg-gray-800 hover:text-white"
31
- >
32
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
33
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
34
- </svg>
26
+ // Render into document.body via a portal. The app shell uses
27
+ // `transform: scale(--ui-zoom)`, which turns it into the containing block
28
+ // for position:fixed descendants so a modal rendered inside it would be
29
+ // positioned relative to the (tall, scrollable) shell instead of the
30
+ // viewport, pushing the dialog off-screen while only the backdrop shows.
31
+ return createPortal(
32
+ <div className="modal-shell">
33
+ <div className="modal-backdrop" onClick={onClose} />
34
+ <div className={`tech-modal ${sizeClasses[size]}`}>
35
+ <div className="modal-header">
36
+ <div>
37
+ <span className="modal-kicker">COMMAND DIALOG</span>
38
+ <h2>{title}</h2>
39
+ </div>
40
+ <button onClick={onClose} className="modal-close" aria-label="Close dialog">
41
+ <X className="h-4 w-4" />
35
42
  </button>
36
43
  </div>
37
- <div className="px-6 py-4">{children}</div>
44
+ <div className="modal-content">{children}</div>
38
45
  </div>
39
- </div>
46
+ </div>,
47
+ document.body
40
48
  );
41
- }
49
+ }
@@ -12,26 +12,23 @@ interface StatCardProps {
12
12
 
13
13
  export function StatCard({ title, value, subtitle, icon, trend, className }: StatCardProps) {
14
14
  return (
15
- <div className={cn("rounded-xl border border-gray-800 bg-gray-900/50 p-5 backdrop-blur-sm", className)}>
16
- <div className="flex items-start justify-between">
15
+ <div className={cn("tech-panel stat-instrument", className)}>
16
+ <span className="panel-corner panel-corner-tl" />
17
+ <span className="panel-corner panel-corner-br" />
18
+ <div className="flex items-start justify-between gap-4">
17
19
  <div className="space-y-1">
18
- <p className="text-sm font-medium text-gray-400">{title}</p>
19
- <p className="text-2xl font-bold tracking-tight text-white">{value}</p>
20
- {subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
20
+ <p className="instrument-label">{title}</p>
21
+ <p className="instrument-value">{value}</p>
22
+ {subtitle && <p className="instrument-meta">{subtitle}</p>}
21
23
  {trend && (
22
- <span
23
- className={cn(
24
- "inline-flex items-center gap-1 text-xs font-medium",
25
- trend.positive ? "text-emerald-400" : "text-red-400"
26
- )}
27
- >
24
+ <span className={cn("instrument-trend", trend.positive ? "is-positive" : "is-negative")}>
28
25
  <span>{trend.positive ? "↑" : "↓"}</span>
29
26
  {trend.value}
30
27
  </span>
31
28
  )}
32
29
  </div>
33
- <div className="rounded-lg bg-blue-500/10 p-2.5 text-blue-400">{icon}</div>
30
+ <div className="instrument-icon">{icon}</div>
34
31
  </div>
35
32
  </div>
36
33
  );
37
- }
34
+ }