@clef-sh/ui 0.1.13-beta.88

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.
Files changed (70) hide show
  1. package/README.md +38 -0
  2. package/dist/client/assets/index-CVpAmirt.js +26 -0
  3. package/dist/client/favicon-96x96.png +0 -0
  4. package/dist/client/favicon.ico +0 -0
  5. package/dist/client/favicon.svg +16 -0
  6. package/dist/client/index.html +50 -0
  7. package/dist/client-lib/api.d.ts +3 -0
  8. package/dist/client-lib/api.d.ts.map +1 -0
  9. package/dist/client-lib/components/Button.d.ts +10 -0
  10. package/dist/client-lib/components/Button.d.ts.map +1 -0
  11. package/dist/client-lib/components/CopyButton.d.ts +6 -0
  12. package/dist/client-lib/components/CopyButton.d.ts.map +1 -0
  13. package/dist/client-lib/components/EnvBadge.d.ts +7 -0
  14. package/dist/client-lib/components/EnvBadge.d.ts.map +1 -0
  15. package/dist/client-lib/components/MatrixGrid.d.ts +13 -0
  16. package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -0
  17. package/dist/client-lib/components/Sidebar.d.ts +16 -0
  18. package/dist/client-lib/components/Sidebar.d.ts.map +1 -0
  19. package/dist/client-lib/components/StatusDot.d.ts +6 -0
  20. package/dist/client-lib/components/StatusDot.d.ts.map +1 -0
  21. package/dist/client-lib/components/TopBar.d.ts +9 -0
  22. package/dist/client-lib/components/TopBar.d.ts.map +1 -0
  23. package/dist/client-lib/index.d.ts +12 -0
  24. package/dist/client-lib/index.d.ts.map +1 -0
  25. package/dist/client-lib/theme.d.ts +42 -0
  26. package/dist/client-lib/theme.d.ts.map +1 -0
  27. package/dist/server/api.d.ts +11 -0
  28. package/dist/server/api.d.ts.map +1 -0
  29. package/dist/server/api.js +1020 -0
  30. package/dist/server/api.js.map +1 -0
  31. package/dist/server/index.d.ts +12 -0
  32. package/dist/server/index.d.ts.map +1 -0
  33. package/dist/server/index.js +231 -0
  34. package/dist/server/index.js.map +1 -0
  35. package/package.json +74 -0
  36. package/src/client/App.tsx +205 -0
  37. package/src/client/api.test.tsx +94 -0
  38. package/src/client/api.ts +30 -0
  39. package/src/client/components/Button.tsx +52 -0
  40. package/src/client/components/CopyButton.test.tsx +43 -0
  41. package/src/client/components/CopyButton.tsx +36 -0
  42. package/src/client/components/EnvBadge.tsx +32 -0
  43. package/src/client/components/MatrixGrid.tsx +265 -0
  44. package/src/client/components/Sidebar.tsx +337 -0
  45. package/src/client/components/StatusDot.tsx +30 -0
  46. package/src/client/components/TopBar.tsx +50 -0
  47. package/src/client/index.html +50 -0
  48. package/src/client/index.ts +18 -0
  49. package/src/client/main.tsx +15 -0
  50. package/src/client/public/favicon-96x96.png +0 -0
  51. package/src/client/public/favicon.ico +0 -0
  52. package/src/client/public/favicon.svg +16 -0
  53. package/src/client/screens/BackendScreen.test.tsx +611 -0
  54. package/src/client/screens/BackendScreen.tsx +836 -0
  55. package/src/client/screens/DiffView.test.tsx +130 -0
  56. package/src/client/screens/DiffView.tsx +547 -0
  57. package/src/client/screens/GitLogView.test.tsx +113 -0
  58. package/src/client/screens/GitLogView.tsx +192 -0
  59. package/src/client/screens/ImportScreen.tsx +710 -0
  60. package/src/client/screens/LintView.test.tsx +143 -0
  61. package/src/client/screens/LintView.tsx +589 -0
  62. package/src/client/screens/MatrixView.test.tsx +138 -0
  63. package/src/client/screens/MatrixView.tsx +143 -0
  64. package/src/client/screens/NamespaceEditor.test.tsx +694 -0
  65. package/src/client/screens/NamespaceEditor.tsx +1122 -0
  66. package/src/client/screens/RecipientsScreen.tsx +696 -0
  67. package/src/client/screens/ScanScreen.test.tsx +323 -0
  68. package/src/client/screens/ScanScreen.tsx +523 -0
  69. package/src/client/screens/ServiceIdentitiesScreen.tsx +1398 -0
  70. package/src/client/theme.ts +48 -0
@@ -0,0 +1,710 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { theme } from "../theme";
3
+ import { apiFetch } from "../api";
4
+ import { TopBar } from "../components/TopBar";
5
+ import { Button } from "../components/Button";
6
+ import type { ClefManifest } from "@clef-sh/core";
7
+ import type { ViewName } from "../components/Sidebar";
8
+
9
+ interface ImportScreenProps {
10
+ manifest: ClefManifest | null;
11
+ setView: (view: ViewName) => void;
12
+ }
13
+
14
+ interface PreviewResult {
15
+ wouldImport: string[];
16
+ wouldSkip: Array<{ key: string; reason: string }>;
17
+ wouldOverwrite: string[];
18
+ warnings: string[];
19
+ totalKeys: number;
20
+ }
21
+
22
+ interface ApplyResult {
23
+ imported: string[];
24
+ skipped: string[];
25
+ failed: Array<{ key: string; error: string }>;
26
+ }
27
+
28
+ type ImportFormatOption = "auto" | "dotenv" | "json" | "yaml";
29
+
30
+ export function ImportScreen({ manifest, setView }: ImportScreenProps) {
31
+ const [step, setStep] = useState<1 | 2 | 3>(1);
32
+ const [namespace, setNamespace] = useState("");
33
+ const [environment, setEnvironment] = useState("");
34
+ const [content, setContent] = useState("");
35
+ const [format, setFormat] = useState<ImportFormatOption>("auto");
36
+ const [preview, setPreview] = useState<PreviewResult | null>(null);
37
+ const [overwriteKeys, setOverwriteKeys] = useState<string[]>([]);
38
+ const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
39
+ const [loading, setLoading] = useState(false);
40
+ const [error, setError] = useState<string | null>(null);
41
+
42
+ // Set defaults from manifest
43
+ useEffect(() => {
44
+ if (manifest) {
45
+ if (!namespace && manifest.namespaces.length > 0) {
46
+ setNamespace(manifest.namespaces[0].name);
47
+ }
48
+ if (!environment && manifest.environments.length > 0) {
49
+ setEnvironment(manifest.environments[0].name);
50
+ }
51
+ }
52
+ }, [manifest, namespace, environment]);
53
+
54
+ const namespaces = manifest?.namespaces ?? [];
55
+ const environments = manifest?.environments ?? [];
56
+
57
+ const handlePreview = async () => {
58
+ if (!namespace || !environment || !content.trim()) {
59
+ setError("Please select a namespace, environment, and paste content.");
60
+ return;
61
+ }
62
+
63
+ setLoading(true);
64
+ setError(null);
65
+
66
+ try {
67
+ const res = await apiFetch("/api/import/preview", {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify({
71
+ target: `${namespace}/${environment}`,
72
+ content,
73
+ format: format === "auto" ? undefined : format,
74
+ overwriteKeys,
75
+ }),
76
+ });
77
+
78
+ if (!res.ok) {
79
+ const data = await res.json();
80
+ setError(data.error ?? "Preview failed");
81
+ return;
82
+ }
83
+
84
+ const data: PreviewResult = await res.json();
85
+ setPreview(data);
86
+ setOverwriteKeys([]);
87
+ setStep(2);
88
+ } catch (err) {
89
+ setError(err instanceof Error ? err.message : "Preview failed");
90
+ } finally {
91
+ setLoading(false);
92
+ }
93
+ };
94
+
95
+ const handleApply = async () => {
96
+ if (!preview) return;
97
+
98
+ setLoading(true);
99
+ setError(null);
100
+
101
+ // Keys to import: wouldImport + any overwrite-toggled ones
102
+ const keysToImport = [
103
+ ...preview.wouldImport,
104
+ ...preview.wouldSkip.filter((s) => overwriteKeys.includes(s.key)).map((s) => s.key),
105
+ ];
106
+
107
+ try {
108
+ const res = await apiFetch("/api/import/apply", {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify({
112
+ target: `${namespace}/${environment}`,
113
+ content,
114
+ format: format === "auto" ? undefined : format,
115
+ keys: keysToImport,
116
+ overwriteKeys,
117
+ }),
118
+ });
119
+
120
+ if (!res.ok) {
121
+ const data = await res.json();
122
+ setError(data.error ?? "Import failed");
123
+ return;
124
+ }
125
+
126
+ const data: ApplyResult = await res.json();
127
+ setApplyResult(data);
128
+ // Clear content after successful apply
129
+ setContent("");
130
+ setStep(3);
131
+ } catch (err) {
132
+ setError(err instanceof Error ? err.message : "Import failed");
133
+ } finally {
134
+ setLoading(false);
135
+ }
136
+ };
137
+
138
+ const handleImportMore = () => {
139
+ setStep(1);
140
+ setContent("");
141
+ setPreview(null);
142
+ setApplyResult(null);
143
+ setOverwriteKeys([]);
144
+ setError(null);
145
+ };
146
+
147
+ const toggleOverwrite = (key: string) => {
148
+ setOverwriteKeys((prev) =>
149
+ prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
150
+ );
151
+ };
152
+
153
+ const importableCount =
154
+ (preview?.wouldImport.length ?? 0) +
155
+ overwriteKeys.filter((k) => preview?.wouldSkip.some((s) => s.key === k)).length;
156
+
157
+ return (
158
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
159
+ <TopBar title="Import" subtitle="clef import — bulk migrate secrets" />
160
+
161
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
162
+ <div style={{ maxWidth: 620, margin: "0 auto" }}>
163
+ {/* Step indicator */}
164
+ <div
165
+ style={{
166
+ display: "flex",
167
+ alignItems: "center",
168
+ gap: 0,
169
+ marginBottom: 32,
170
+ }}
171
+ >
172
+ {([1, 2, 3] as const).map((s, i) => (
173
+ <React.Fragment key={s}>
174
+ <div
175
+ style={{
176
+ display: "flex",
177
+ alignItems: "center",
178
+ gap: 8,
179
+ }}
180
+ >
181
+ <div
182
+ style={{
183
+ width: 24,
184
+ height: 24,
185
+ borderRadius: "50%",
186
+ background: step >= s ? theme.accent : theme.surface,
187
+ border: `1px solid ${step >= s ? theme.accent : theme.border}`,
188
+ display: "flex",
189
+ alignItems: "center",
190
+ justifyContent: "center",
191
+ fontFamily: theme.mono,
192
+ fontSize: 11,
193
+ fontWeight: 700,
194
+ color: step >= s ? "#000" : theme.textDim,
195
+ }}
196
+ >
197
+ {s}
198
+ </div>
199
+ <span
200
+ style={{
201
+ fontFamily: theme.sans,
202
+ fontSize: 12,
203
+ color: step >= s ? theme.text : theme.textDim,
204
+ fontWeight: step === s ? 600 : 400,
205
+ }}
206
+ >
207
+ {s === 1 ? "Source" : s === 2 ? "Preview" : "Done"}
208
+ </span>
209
+ </div>
210
+ {i < 2 && (
211
+ <div
212
+ style={{
213
+ flex: 1,
214
+ height: 1,
215
+ background: step > s ? theme.accent : theme.border,
216
+ margin: "0 12px",
217
+ minWidth: 40,
218
+ }}
219
+ />
220
+ )}
221
+ </React.Fragment>
222
+ ))}
223
+ </div>
224
+
225
+ {error && (
226
+ <div
227
+ style={{
228
+ background: theme.redDim,
229
+ border: `1px solid ${theme.red}44`,
230
+ borderRadius: 8,
231
+ padding: "12px 16px",
232
+ marginBottom: 16,
233
+ fontFamily: theme.sans,
234
+ fontSize: 13,
235
+ color: theme.red,
236
+ }}
237
+ >
238
+ {error}
239
+ </div>
240
+ )}
241
+
242
+ {/* ── Step 1: Source ─────────────────────────────────────────── */}
243
+ {step === 1 && (
244
+ <div>
245
+ {/* Target selectors */}
246
+ <div style={{ marginBottom: 20 }}>
247
+ <Label>Target</Label>
248
+ <div style={{ display: "flex", gap: 12 }}>
249
+ <div style={{ flex: 1 }}>
250
+ <SubLabel>Namespace</SubLabel>
251
+ <Select value={namespace} onChange={(e) => setNamespace(e.target.value)}>
252
+ {namespaces.map((ns) => (
253
+ <option key={ns.name} value={ns.name}>
254
+ {ns.name}
255
+ </option>
256
+ ))}
257
+ </Select>
258
+ </div>
259
+ <div style={{ flex: 1 }}>
260
+ <SubLabel>Environment</SubLabel>
261
+ <Select value={environment} onChange={(e) => setEnvironment(e.target.value)}>
262
+ {environments.map((env) => (
263
+ <option key={env.name} value={env.name}>
264
+ {env.name}
265
+ </option>
266
+ ))}
267
+ </Select>
268
+ </div>
269
+ </div>
270
+ </div>
271
+
272
+ {/* Format selector */}
273
+ <div style={{ marginBottom: 20 }}>
274
+ <Label>Format</Label>
275
+ <div style={{ display: "flex", gap: 16 }}>
276
+ {(["auto", "dotenv", "json", "yaml"] as const).map((f) => (
277
+ <label
278
+ key={f}
279
+ style={{
280
+ display: "flex",
281
+ alignItems: "center",
282
+ gap: 6,
283
+ cursor: "pointer",
284
+ fontFamily: theme.sans,
285
+ fontSize: 13,
286
+ color: format === f ? theme.text : theme.textMuted,
287
+ }}
288
+ >
289
+ <input
290
+ type="radio"
291
+ name="format"
292
+ value={f}
293
+ checked={format === f}
294
+ onChange={() => setFormat(f)}
295
+ style={{ accentColor: theme.accent }}
296
+ />
297
+ {f === "auto" ? "Auto" : f}
298
+ </label>
299
+ ))}
300
+ </div>
301
+ </div>
302
+
303
+ {/* Content textarea */}
304
+ <div style={{ marginBottom: 8 }}>
305
+ <Label>Paste secrets</Label>
306
+ <textarea
307
+ value={content}
308
+ onChange={(e) => setContent(e.target.value)}
309
+ placeholder={
310
+ format === "json"
311
+ ? '{\n "DB_HOST": "localhost",\n "DB_PORT": "5432"\n}'
312
+ : format === "yaml"
313
+ ? "DB_HOST: localhost\nDB_PORT: '5432'"
314
+ : "DB_HOST=localhost\nDB_PORT=5432\n# Comments are ignored"
315
+ }
316
+ rows={12}
317
+ style={{
318
+ width: "100%",
319
+ background: theme.surface,
320
+ border: `1px solid ${theme.border}`,
321
+ borderRadius: 8,
322
+ padding: 14,
323
+ fontFamily: theme.mono,
324
+ fontSize: 12,
325
+ color: theme.text,
326
+ resize: "vertical",
327
+ outline: "none",
328
+ boxSizing: "border-box",
329
+ }}
330
+ />
331
+ </div>
332
+
333
+ {/* Privacy notice */}
334
+ <div
335
+ style={{
336
+ marginBottom: 24,
337
+ padding: "10px 14px",
338
+ background: theme.surface,
339
+ border: `1px solid ${theme.border}`,
340
+ borderRadius: 6,
341
+ fontFamily: theme.sans,
342
+ fontSize: 11,
343
+ color: theme.textMuted,
344
+ lineHeight: 1.5,
345
+ }}
346
+ >
347
+ Values are sent directly to the local Clef server (127.0.0.1) and encrypted
348
+ immediately. They are never stored in browser memory beyond this session.
349
+ </div>
350
+
351
+ <Button
352
+ variant="primary"
353
+ onClick={handlePreview}
354
+ disabled={loading || !content.trim()}
355
+ >
356
+ {loading ? "Previewing..." : "Next: Preview"}
357
+ </Button>
358
+ </div>
359
+ )}
360
+
361
+ {/* ── Step 2: Preview ────────────────────────────────────────── */}
362
+ {step === 2 && preview && (
363
+ <div>
364
+ <div
365
+ style={{
366
+ fontFamily: theme.sans,
367
+ fontSize: 13,
368
+ color: theme.textMuted,
369
+ marginBottom: 20,
370
+ }}
371
+ >
372
+ Importing to{" "}
373
+ <span style={{ color: theme.accent, fontWeight: 600 }}>
374
+ {namespace}/{environment}
375
+ </span>
376
+ . {preview.totalKeys} key{preview.totalKeys !== 1 ? "s" : ""} parsed.
377
+ </div>
378
+
379
+ {/* Warnings */}
380
+ {preview.warnings.length > 0 && (
381
+ <div style={{ marginBottom: 16 }}>
382
+ {preview.warnings.map((w, i) => (
383
+ <div
384
+ key={i}
385
+ style={{
386
+ fontFamily: theme.mono,
387
+ fontSize: 11,
388
+ color: theme.yellow,
389
+ marginBottom: 4,
390
+ }}
391
+ >
392
+ &#9888; {w}
393
+ </div>
394
+ ))}
395
+ </div>
396
+ )}
397
+
398
+ {/* Would import */}
399
+ {preview.wouldImport.length > 0 && (
400
+ <div style={{ marginBottom: 16 }}>
401
+ <SectionLabel color={theme.green}>
402
+ New keys ({preview.wouldImport.length})
403
+ </SectionLabel>
404
+ {preview.wouldImport.map((key) => (
405
+ <KeyRow key={key} icon="\u2192" iconColor={theme.green} label={key} />
406
+ ))}
407
+ </div>
408
+ )}
409
+
410
+ {/* Would skip / overwrite toggles */}
411
+ {preview.wouldSkip.length > 0 && (
412
+ <div style={{ marginBottom: 16 }}>
413
+ <SectionLabel color={theme.yellow}>
414
+ Already exists ({preview.wouldSkip.length}) — toggle to overwrite
415
+ </SectionLabel>
416
+ {preview.wouldSkip.map(({ key, reason }) => {
417
+ const willOverwrite = overwriteKeys.includes(key);
418
+ return (
419
+ <div
420
+ key={key}
421
+ style={{
422
+ display: "flex",
423
+ alignItems: "center",
424
+ gap: 10,
425
+ padding: "6px 10px",
426
+ borderRadius: 6,
427
+ marginBottom: 4,
428
+ background: willOverwrite ? theme.yellowDim : "transparent",
429
+ border: `1px solid ${willOverwrite ? theme.yellow + "44" : theme.border}`,
430
+ }}
431
+ >
432
+ <input
433
+ type="checkbox"
434
+ checked={willOverwrite}
435
+ onChange={() => toggleOverwrite(key)}
436
+ style={{ accentColor: theme.yellow }}
437
+ id={`overwrite-${key}`}
438
+ />
439
+ <label
440
+ htmlFor={`overwrite-${key}`}
441
+ style={{
442
+ fontFamily: theme.mono,
443
+ fontSize: 12,
444
+ color: willOverwrite ? theme.yellow : theme.textMuted,
445
+ flex: 1,
446
+ cursor: "pointer",
447
+ }}
448
+ >
449
+ {key}
450
+ </label>
451
+ <span
452
+ style={{
453
+ fontFamily: theme.sans,
454
+ fontSize: 11,
455
+ color: theme.textDim,
456
+ }}
457
+ >
458
+ {reason}
459
+ </span>
460
+ </div>
461
+ );
462
+ })}
463
+ </div>
464
+ )}
465
+
466
+ {preview.wouldImport.length === 0 && preview.wouldSkip.length === 0 && (
467
+ <div
468
+ style={{
469
+ fontFamily: theme.sans,
470
+ fontSize: 13,
471
+ color: theme.textMuted,
472
+ padding: "24px 0",
473
+ textAlign: "center",
474
+ }}
475
+ >
476
+ No importable keys found.
477
+ </div>
478
+ )}
479
+
480
+ <div style={{ display: "flex", gap: 10, marginTop: 24 }}>
481
+ <Button variant="ghost" onClick={() => setStep(1)}>
482
+ Back
483
+ </Button>
484
+ <Button
485
+ variant="primary"
486
+ onClick={handleApply}
487
+ disabled={loading || importableCount === 0}
488
+ >
489
+ {loading
490
+ ? "Importing..."
491
+ : `Import ${importableCount} key${importableCount !== 1 ? "s" : ""}`}
492
+ </Button>
493
+ </div>
494
+ </div>
495
+ )}
496
+
497
+ {/* ── Step 3: Done ───────────────────────────────────────────── */}
498
+ {step === 3 && applyResult && (
499
+ <div>
500
+ <div
501
+ style={{
502
+ display: "flex",
503
+ flexDirection: "column",
504
+ alignItems: "center",
505
+ paddingTop: 20,
506
+ paddingBottom: 32,
507
+ }}
508
+ >
509
+ <div
510
+ style={{
511
+ width: 56,
512
+ height: 56,
513
+ borderRadius: "50%",
514
+ background: applyResult.failed.length > 0 ? theme.redDim : theme.greenDim,
515
+ border: `1px solid ${applyResult.failed.length > 0 ? theme.red + "44" : theme.green + "44"}`,
516
+ display: "flex",
517
+ alignItems: "center",
518
+ justifyContent: "center",
519
+ fontSize: 24,
520
+ color: applyResult.failed.length > 0 ? theme.red : theme.green,
521
+ marginBottom: 16,
522
+ }}
523
+ >
524
+ {applyResult.failed.length > 0 ? "\u26a0" : "\u2713"}
525
+ </div>
526
+
527
+ <div
528
+ style={{
529
+ fontFamily: theme.sans,
530
+ fontWeight: 600,
531
+ fontSize: 16,
532
+ color: applyResult.failed.length > 0 ? theme.yellow : theme.green,
533
+ marginBottom: 8,
534
+ }}
535
+ >
536
+ {applyResult.failed.length > 0
537
+ ? "Import completed with errors"
538
+ : "Import complete"}
539
+ </div>
540
+
541
+ <div
542
+ style={{
543
+ fontFamily: theme.mono,
544
+ fontSize: 12,
545
+ color: theme.textMuted,
546
+ }}
547
+ >
548
+ {applyResult.imported.length} imported, {applyResult.skipped.length} skipped,{" "}
549
+ {applyResult.failed.length} failed
550
+ </div>
551
+ </div>
552
+
553
+ {applyResult.imported.length > 0 && (
554
+ <div style={{ marginBottom: 16 }}>
555
+ <SectionLabel color={theme.green}>
556
+ Imported ({applyResult.imported.length})
557
+ </SectionLabel>
558
+ {applyResult.imported.map((key) => (
559
+ <KeyRow key={key} icon="\u2713" iconColor={theme.green} label={key} />
560
+ ))}
561
+ </div>
562
+ )}
563
+
564
+ {applyResult.failed.length > 0 && (
565
+ <div style={{ marginBottom: 16 }}>
566
+ <SectionLabel color={theme.red}>
567
+ Failed ({applyResult.failed.length})
568
+ </SectionLabel>
569
+ {applyResult.failed.map(({ key, error: keyError }) => (
570
+ <KeyRow
571
+ key={key}
572
+ icon="\u2717"
573
+ iconColor={theme.red}
574
+ label={key}
575
+ note={keyError}
576
+ />
577
+ ))}
578
+ </div>
579
+ )}
580
+
581
+ <div style={{ display: "flex", gap: 10, marginTop: 24 }}>
582
+ <Button variant="primary" onClick={() => setView("matrix")}>
583
+ View in Matrix
584
+ </Button>
585
+ <Button variant="ghost" onClick={handleImportMore}>
586
+ Import more
587
+ </Button>
588
+ </div>
589
+ </div>
590
+ )}
591
+ </div>
592
+ </div>
593
+ </div>
594
+ );
595
+ }
596
+
597
+ function Label({ children }: { children: React.ReactNode }) {
598
+ return (
599
+ <div
600
+ style={{
601
+ fontFamily: theme.sans,
602
+ fontSize: 12,
603
+ fontWeight: 600,
604
+ color: theme.textMuted,
605
+ marginBottom: 8,
606
+ letterSpacing: "0.05em",
607
+ textTransform: "uppercase",
608
+ }}
609
+ >
610
+ {children}
611
+ </div>
612
+ );
613
+ }
614
+
615
+ function SubLabel({ children }: { children: React.ReactNode }) {
616
+ return (
617
+ <div
618
+ style={{
619
+ fontFamily: theme.sans,
620
+ fontSize: 11,
621
+ color: theme.textDim,
622
+ marginBottom: 4,
623
+ }}
624
+ >
625
+ {children}
626
+ </div>
627
+ );
628
+ }
629
+
630
+ function Select({
631
+ value,
632
+ onChange,
633
+ children,
634
+ }: {
635
+ value: string;
636
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
637
+ children: React.ReactNode;
638
+ }) {
639
+ return (
640
+ <select
641
+ value={value}
642
+ onChange={onChange}
643
+ style={{
644
+ width: "100%",
645
+ background: theme.surface,
646
+ border: `1px solid ${theme.border}`,
647
+ borderRadius: 6,
648
+ padding: "7px 10px",
649
+ fontFamily: theme.sans,
650
+ fontSize: 13,
651
+ color: theme.text,
652
+ outline: "none",
653
+ cursor: "pointer",
654
+ }}
655
+ >
656
+ {children}
657
+ </select>
658
+ );
659
+ }
660
+
661
+ function SectionLabel({ children, color }: { children: React.ReactNode; color: string }) {
662
+ return (
663
+ <div
664
+ style={{
665
+ fontFamily: theme.sans,
666
+ fontSize: 11,
667
+ fontWeight: 600,
668
+ color,
669
+ letterSpacing: "0.06em",
670
+ textTransform: "uppercase",
671
+ marginBottom: 8,
672
+ }}
673
+ >
674
+ {children}
675
+ </div>
676
+ );
677
+ }
678
+
679
+ function KeyRow({
680
+ icon,
681
+ iconColor,
682
+ label,
683
+ note,
684
+ }: {
685
+ icon: string;
686
+ iconColor: string;
687
+ label: string;
688
+ note?: string;
689
+ }) {
690
+ return (
691
+ <div
692
+ style={{
693
+ display: "flex",
694
+ alignItems: "center",
695
+ gap: 10,
696
+ padding: "5px 10px",
697
+ borderRadius: 6,
698
+ marginBottom: 3,
699
+ }}
700
+ >
701
+ <span style={{ color: iconColor, fontFamily: theme.mono, fontSize: 13 }}>{icon}</span>
702
+ <span style={{ fontFamily: theme.mono, fontSize: 12, color: theme.text, flex: 1 }}>
703
+ {label}
704
+ </span>
705
+ {note && (
706
+ <span style={{ fontFamily: theme.sans, fontSize: 11, color: theme.textDim }}>{note}</span>
707
+ )}
708
+ </div>
709
+ );
710
+ }