@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,130 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, act } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { DiffView } from "./DiffView";
5
+ import type { ClefManifest, DiffResult } from "@clef-sh/core";
6
+
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ declare let global: any;
9
+
10
+ const manifest: ClefManifest = {
11
+ version: 1,
12
+ environments: [
13
+ { name: "dev", description: "Dev" },
14
+ { name: "production", description: "Prod" },
15
+ ],
16
+ namespaces: [{ name: "database", description: "DB" }],
17
+ sops: { default_backend: "age" },
18
+ file_pattern: "{namespace}/{environment}.enc.yaml",
19
+ };
20
+
21
+ const mockDiff: DiffResult = {
22
+ namespace: "database",
23
+ envA: "dev",
24
+ envB: "production",
25
+ rows: [
26
+ { key: "DB_HOST", valueA: "localhost", valueB: "prod-host", status: "changed" },
27
+ { key: "DB_PORT", valueA: "5432", valueB: "5432", status: "identical" },
28
+ { key: "DB_REPLICA", valueA: null, valueB: "replica-host", status: "missing_a" },
29
+ ],
30
+ };
31
+
32
+ beforeEach(() => {
33
+ jest.clearAllMocks();
34
+ jest.restoreAllMocks();
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ delete (global as any).fetch;
37
+ });
38
+
39
+ describe("DiffView", () => {
40
+ it("renders diff table with data", async () => {
41
+ global.fetch = jest.fn().mockResolvedValue({
42
+ ok: true,
43
+ json: () => Promise.resolve(mockDiff),
44
+ } as Response);
45
+
46
+ await act(async () => {
47
+ render(<DiffView manifest={manifest} />);
48
+ });
49
+
50
+ expect(screen.getByText("DB_HOST")).toBeInTheDocument();
51
+ expect(screen.getAllByText("DB_REPLICA").length).toBeGreaterThan(0);
52
+ expect(screen.getByTestId("diff-table")).toBeInTheDocument();
53
+ expect(screen.getByText("1 changed")).toBeInTheDocument();
54
+ });
55
+
56
+ it("hides identical rows by default", async () => {
57
+ global.fetch = jest.fn().mockResolvedValue({
58
+ ok: true,
59
+ json: () => Promise.resolve(mockDiff),
60
+ } as Response);
61
+
62
+ await act(async () => {
63
+ render(<DiffView manifest={manifest} />);
64
+ });
65
+
66
+ expect(screen.getByText("DB_HOST")).toBeInTheDocument();
67
+ expect(screen.queryByText("DB_PORT")).not.toBeInTheDocument();
68
+ });
69
+
70
+ it("shows identical rows when checkbox is checked", async () => {
71
+ global.fetch = jest.fn().mockResolvedValue({
72
+ ok: true,
73
+ json: () => Promise.resolve(mockDiff),
74
+ } as Response);
75
+
76
+ await act(async () => {
77
+ render(<DiffView manifest={manifest} />);
78
+ });
79
+
80
+ await act(async () => {
81
+ fireEvent.click(screen.getByLabelText("Show identical"));
82
+ });
83
+
84
+ expect(screen.getByText("DB_PORT")).toBeInTheDocument();
85
+ });
86
+
87
+ it("shows fix hint for missing keys", async () => {
88
+ global.fetch = jest.fn().mockResolvedValue({
89
+ ok: true,
90
+ json: () => Promise.resolve(mockDiff),
91
+ } as Response);
92
+
93
+ await act(async () => {
94
+ render(<DiffView manifest={manifest} />);
95
+ });
96
+
97
+ expect(screen.getByTestId("fix-hint")).toBeInTheDocument();
98
+ expect(screen.getByText(/clef set database\/dev DB_REPLICA/)).toBeInTheDocument();
99
+ });
100
+
101
+ it("shows coming soon toast when sync button is clicked", async () => {
102
+ global.fetch = jest.fn().mockResolvedValue({
103
+ ok: true,
104
+ json: () => Promise.resolve(mockDiff),
105
+ } as Response);
106
+
107
+ await act(async () => {
108
+ render(<DiffView manifest={manifest} />);
109
+ });
110
+
111
+ await act(async () => {
112
+ fireEvent.click(screen.getByTestId("sync-missing-btn"));
113
+ });
114
+
115
+ expect(screen.getByTestId("coming-soon-toast")).toBeInTheDocument();
116
+ });
117
+
118
+ it("renders empty state when no manifest", async () => {
119
+ global.fetch = jest.fn().mockResolvedValue({
120
+ ok: true,
121
+ json: () => Promise.resolve({ namespace: "", envA: "", envB: "", rows: [] }),
122
+ } as Response);
123
+
124
+ await act(async () => {
125
+ render(<DiffView manifest={null} />);
126
+ });
127
+
128
+ expect(screen.getByText("Environment Diff")).toBeInTheDocument();
129
+ });
130
+ });
@@ -0,0 +1,547 @@
1
+ import React, { useState, useEffect, useCallback } 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 { EnvBadge } from "../components/EnvBadge";
7
+ import { CopyButton } from "../components/CopyButton";
8
+ import type { ClefManifest, DiffResult } from "@clef-sh/core";
9
+
10
+ interface DiffViewProps {
11
+ manifest: ClefManifest | null;
12
+ }
13
+
14
+ function truncate(s: string, max: number): string {
15
+ return s.length > max ? s.slice(0, max) + "\u2026" : s;
16
+ }
17
+
18
+ export function DiffView({ manifest }: DiffViewProps) {
19
+ const environments = manifest?.environments ?? [];
20
+ const namespaces = manifest?.namespaces ?? [];
21
+
22
+ const [ns, setNs] = useState(namespaces[0]?.name ?? "");
23
+ const [envA, setEnvA] = useState(environments[0]?.name ?? "");
24
+ const [envB, setEnvB] = useState(environments[environments.length - 1]?.name ?? "");
25
+ const [showSame, setShowSame] = useState(false);
26
+ const [showValues, setShowValues] = useState(false);
27
+ const [diffResult, setDiffResult] = useState<DiffResult | null>(null);
28
+ const [loading, setLoading] = useState(false);
29
+ const [toastVisible, setToastVisible] = useState(false);
30
+
31
+ useEffect(() => {
32
+ if (namespaces.length > 0 && !ns) setNs(namespaces[0].name);
33
+ if (environments.length > 0 && !envA) setEnvA(environments[0].name);
34
+ if (environments.length > 1 && !envB) setEnvB(environments[environments.length - 1].name);
35
+ }, [namespaces, environments, ns, envA, envB]);
36
+
37
+ const loadDiff = useCallback(async () => {
38
+ if (!ns || !envA || !envB || envA === envB) return;
39
+ setLoading(true);
40
+ try {
41
+ const qs = showValues ? "?showValues=true" : "";
42
+ const res = await apiFetch(`/api/diff/${ns}/${envA}/${envB}${qs}`);
43
+ if (res.ok) {
44
+ setDiffResult(await res.json());
45
+ } else {
46
+ setDiffResult(null);
47
+ }
48
+ } catch {
49
+ setDiffResult(null);
50
+ } finally {
51
+ setLoading(false);
52
+ }
53
+ }, [ns, envA, envB, showValues]);
54
+
55
+ useEffect(() => {
56
+ loadDiff();
57
+ }, [loadDiff]);
58
+
59
+ const rows = diffResult?.rows ?? [];
60
+ const filtered = rows.filter((r) => showSame || r.status !== "identical");
61
+
62
+ const changedCount = rows.filter((r) => r.status === "changed").length;
63
+ const missingACount = rows.filter((r) => r.status === "missing_a").length;
64
+ const missingBCount = rows.filter((r) => r.status === "missing_b").length;
65
+ const identicalCount = rows.filter((r) => r.status === "identical").length;
66
+ const missingRows = rows.filter((r) => r.status === "missing_a" || r.status === "missing_b");
67
+
68
+ const statusMeta: Record<string, { label: string; color: string }> = {
69
+ changed: { label: "Changed", color: theme.yellow },
70
+ identical: { label: "Identical", color: theme.textMuted },
71
+ missing_a: { label: `Missing in ${envA}`, color: theme.red },
72
+ missing_b: { label: `Missing in ${envB}`, color: theme.red },
73
+ };
74
+
75
+ return (
76
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
77
+ <TopBar
78
+ title="Environment Diff"
79
+ subtitle="Compare secrets across environments"
80
+ actions={
81
+ <Button
82
+ variant="primary"
83
+ data-testid="sync-missing-btn"
84
+ onClick={() => {
85
+ setToastVisible(true);
86
+ setTimeout(() => setToastVisible(false), 2000);
87
+ }}
88
+ >
89
+ Sync missing keys {"\u2192"}
90
+ </Button>
91
+ }
92
+ />
93
+
94
+ {/* Toast */}
95
+ {toastVisible && (
96
+ <div
97
+ data-testid="coming-soon-toast"
98
+ style={{
99
+ position: "fixed",
100
+ top: 20,
101
+ right: 20,
102
+ padding: "10px 18px",
103
+ background: theme.surface,
104
+ border: `1px solid ${theme.accent}44`,
105
+ borderRadius: 8,
106
+ fontFamily: theme.sans,
107
+ fontSize: 12,
108
+ color: theme.accent,
109
+ zIndex: 1000,
110
+ }}
111
+ >
112
+ Coming soon
113
+ </div>
114
+ )}
115
+
116
+ {/* Controls */}
117
+ <div
118
+ style={{
119
+ padding: "14px 24px",
120
+ background: "#0D0F14",
121
+ borderBottom: `1px solid ${theme.border}`,
122
+ display: "flex",
123
+ alignItems: "center",
124
+ gap: 12,
125
+ flexWrap: "wrap",
126
+ }}
127
+ >
128
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
129
+ <span
130
+ style={{
131
+ fontFamily: theme.sans,
132
+ fontSize: 12,
133
+ color: theme.textMuted,
134
+ }}
135
+ >
136
+ Namespace
137
+ </span>
138
+ <select
139
+ value={ns}
140
+ onChange={(e) => setNs(e.target.value)}
141
+ style={{
142
+ background: theme.surface,
143
+ border: `1px solid ${theme.border}`,
144
+ borderRadius: 6,
145
+ padding: "5px 10px",
146
+ fontFamily: theme.mono,
147
+ fontSize: 12,
148
+ color: theme.text,
149
+ cursor: "pointer",
150
+ }}
151
+ >
152
+ {namespaces.map((n) => (
153
+ <option key={n.name} value={n.name}>
154
+ {n.name}
155
+ </option>
156
+ ))}
157
+ </select>
158
+ </div>
159
+
160
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
161
+ <span
162
+ style={{
163
+ fontFamily: theme.sans,
164
+ fontSize: 12,
165
+ color: theme.textMuted,
166
+ }}
167
+ >
168
+ Compare
169
+ </span>
170
+ <select
171
+ value={envA}
172
+ onChange={(e) => setEnvA(e.target.value)}
173
+ style={{
174
+ background: theme.surface,
175
+ border: `1px solid ${theme.border}`,
176
+ borderRadius: 6,
177
+ padding: "5px 10px",
178
+ fontFamily: theme.mono,
179
+ fontSize: 12,
180
+ color: theme.text,
181
+ cursor: "pointer",
182
+ }}
183
+ >
184
+ {environments.map((e) => (
185
+ <option key={e.name} value={e.name}>
186
+ {e.name}
187
+ </option>
188
+ ))}
189
+ </select>
190
+ <span
191
+ style={{
192
+ fontFamily: theme.mono,
193
+ fontSize: 12,
194
+ color: theme.textDim,
195
+ }}
196
+ >
197
+ {"\u2192"}
198
+ </span>
199
+ <select
200
+ value={envB}
201
+ onChange={(e) => setEnvB(e.target.value)}
202
+ style={{
203
+ background: theme.surface,
204
+ border: `1px solid ${theme.border}`,
205
+ borderRadius: 6,
206
+ padding: "5px 10px",
207
+ fontFamily: theme.mono,
208
+ fontSize: 12,
209
+ color: theme.text,
210
+ cursor: "pointer",
211
+ }}
212
+ >
213
+ {environments.map((e) => (
214
+ <option key={e.name} value={e.name}>
215
+ {e.name}
216
+ </option>
217
+ ))}
218
+ </select>
219
+ </div>
220
+
221
+ <div style={{ flex: 1 }} />
222
+
223
+ <label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
224
+ <input
225
+ type="checkbox"
226
+ checked={showValues}
227
+ onChange={(e) => setShowValues(e.target.checked)}
228
+ data-testid="show-values-toggle"
229
+ style={{ accentColor: theme.accent }}
230
+ />
231
+ <span
232
+ style={{
233
+ fontFamily: theme.sans,
234
+ fontSize: 12,
235
+ color: theme.textMuted,
236
+ }}
237
+ >
238
+ Show values
239
+ </span>
240
+ </label>
241
+
242
+ <label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
243
+ <input
244
+ type="checkbox"
245
+ checked={showSame}
246
+ onChange={(e) => setShowSame(e.target.checked)}
247
+ style={{ accentColor: theme.accent }}
248
+ />
249
+ <span
250
+ style={{
251
+ fontFamily: theme.sans,
252
+ fontSize: 12,
253
+ color: theme.textMuted,
254
+ }}
255
+ >
256
+ Show identical
257
+ </span>
258
+ </label>
259
+ </div>
260
+
261
+ {/* Summary strip */}
262
+ <div
263
+ style={{
264
+ padding: "10px 24px",
265
+ display: "flex",
266
+ gap: 10,
267
+ borderBottom: `1px solid ${theme.border}`,
268
+ }}
269
+ >
270
+ {[
271
+ { label: `${changedCount} changed`, color: theme.yellow },
272
+ ...(missingACount > 0
273
+ ? [{ label: `${missingACount} missing in ${envA}`, color: theme.red }]
274
+ : []),
275
+ ...(missingBCount > 0
276
+ ? [{ label: `${missingBCount} missing in ${envB}`, color: theme.red }]
277
+ : []),
278
+ { label: `${identicalCount} identical`, color: theme.textMuted },
279
+ ].map((p) => (
280
+ <span
281
+ key={p.label}
282
+ style={{
283
+ fontFamily: theme.mono,
284
+ fontSize: 11,
285
+ color: p.color,
286
+ background: `${p.color}14`,
287
+ border: `1px solid ${p.color}33`,
288
+ borderRadius: 20,
289
+ padding: "2px 10px",
290
+ }}
291
+ >
292
+ {p.label}
293
+ </span>
294
+ ))}
295
+ </div>
296
+
297
+ <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
298
+ {loading && <p style={{ color: theme.textMuted, fontFamily: theme.sans }}>Loading...</p>}
299
+
300
+ {!loading && (
301
+ <>
302
+ <div
303
+ data-testid="diff-table"
304
+ style={{
305
+ background: theme.surface,
306
+ border: `1px solid ${theme.border}`,
307
+ borderRadius: 10,
308
+ overflow: "hidden",
309
+ }}
310
+ >
311
+ {/* Header */}
312
+ <div
313
+ style={{
314
+ display: "grid",
315
+ gridTemplateColumns: "220px 1fr 1fr 100px",
316
+ background: "#0D0F14",
317
+ padding: "10px 20px",
318
+ borderBottom: `1px solid ${theme.border}`,
319
+ }}
320
+ >
321
+ <span
322
+ style={{
323
+ fontFamily: theme.sans,
324
+ fontSize: 11,
325
+ fontWeight: 600,
326
+ color: theme.textMuted,
327
+ textTransform: "uppercase",
328
+ letterSpacing: "0.07em",
329
+ }}
330
+ >
331
+ Key
332
+ </span>
333
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
334
+ <EnvBadge env={envA} small />
335
+ <span
336
+ style={{
337
+ fontFamily: theme.sans,
338
+ fontSize: 11,
339
+ fontWeight: 600,
340
+ color: theme.textMuted,
341
+ textTransform: "uppercase",
342
+ }}
343
+ >
344
+ {envA}
345
+ </span>
346
+ </div>
347
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
348
+ <EnvBadge env={envB} small />
349
+ <span
350
+ style={{
351
+ fontFamily: theme.sans,
352
+ fontSize: 11,
353
+ fontWeight: 600,
354
+ color: theme.textMuted,
355
+ textTransform: "uppercase",
356
+ }}
357
+ >
358
+ {envB}
359
+ </span>
360
+ </div>
361
+ <span
362
+ style={{
363
+ fontFamily: theme.sans,
364
+ fontSize: 11,
365
+ fontWeight: 600,
366
+ color: theme.textMuted,
367
+ textTransform: "uppercase",
368
+ }}
369
+ >
370
+ Status
371
+ </span>
372
+ </div>
373
+
374
+ {filtered.map((row, i) => {
375
+ const meta = statusMeta[row.status];
376
+ return (
377
+ <div
378
+ key={row.key}
379
+ style={{
380
+ display: "grid",
381
+ gridTemplateColumns: "220px 1fr 1fr 100px",
382
+ padding: "0 20px",
383
+ minHeight: 48,
384
+ alignItems: "center",
385
+ borderBottom: i < filtered.length - 1 ? `1px solid ${theme.border}` : "none",
386
+ background:
387
+ row.status === "changed"
388
+ ? `${theme.yellow}06`
389
+ : row.status.startsWith("missing")
390
+ ? `${theme.red}06`
391
+ : "transparent",
392
+ }}
393
+ >
394
+ <span
395
+ style={{
396
+ fontFamily: theme.mono,
397
+ fontSize: 12,
398
+ color: theme.text,
399
+ paddingRight: 16,
400
+ }}
401
+ >
402
+ {row.key}
403
+ </span>
404
+
405
+ {/* Env A value */}
406
+ <div style={{ paddingRight: 16 }}>
407
+ {row.valueA !== null ? (
408
+ <span
409
+ style={{
410
+ fontFamily: theme.mono,
411
+ fontSize: 11,
412
+ color: row.status === "changed" ? theme.yellow : theme.textMuted,
413
+ background: row.status === "changed" ? theme.yellowDim : "transparent",
414
+ padding: row.status === "changed" ? "2px 6px" : "0",
415
+ borderRadius: 3,
416
+ }}
417
+ >
418
+ {truncate(row.valueA, 36)}
419
+ </span>
420
+ ) : (
421
+ <span
422
+ style={{
423
+ fontFamily: theme.mono,
424
+ fontSize: 11,
425
+ color: theme.textDim,
426
+ fontStyle: "italic",
427
+ }}
428
+ >
429
+ {"\u2014"} not set {"\u2014"}
430
+ </span>
431
+ )}
432
+ </div>
433
+
434
+ {/* Env B value */}
435
+ <div style={{ paddingRight: 16 }}>
436
+ {row.valueB !== null ? (
437
+ <span
438
+ style={{
439
+ fontFamily: theme.mono,
440
+ fontSize: 11,
441
+ color: row.status === "changed" ? theme.blue : theme.textMuted,
442
+ background: row.status === "changed" ? theme.blueDim : "transparent",
443
+ padding: row.status === "changed" ? "2px 6px" : "0",
444
+ borderRadius: 3,
445
+ }}
446
+ >
447
+ {truncate(row.valueB, 36)}
448
+ </span>
449
+ ) : (
450
+ <span
451
+ style={{
452
+ fontFamily: theme.mono,
453
+ fontSize: 11,
454
+ color: theme.textDim,
455
+ fontStyle: "italic",
456
+ }}
457
+ >
458
+ {"\u2014"} not set {"\u2014"}
459
+ </span>
460
+ )}
461
+ </div>
462
+
463
+ {/* Status badge */}
464
+ <span
465
+ style={{
466
+ fontFamily: theme.mono,
467
+ fontSize: 10,
468
+ fontWeight: 600,
469
+ color: meta.color,
470
+ background: `${meta.color}18`,
471
+ border: `1px solid ${meta.color}33`,
472
+ borderRadius: 3,
473
+ padding: "2px 8px",
474
+ display: "inline-block",
475
+ }}
476
+ >
477
+ {meta.label}
478
+ </span>
479
+ </div>
480
+ );
481
+ })}
482
+ </div>
483
+
484
+ {/* Inline fix hint */}
485
+ {missingRows.length > 0 && (
486
+ <div
487
+ data-testid="fix-hint"
488
+ style={{
489
+ marginTop: 20,
490
+ padding: "14px 18px",
491
+ background: theme.surface,
492
+ border: `1px solid ${theme.border}`,
493
+ borderRadius: 8,
494
+ display: "flex",
495
+ flexDirection: "column",
496
+ gap: 10,
497
+ }}
498
+ >
499
+ {missingRows.map((row) => {
500
+ const missingEnv = row.status === "missing_a" ? envA : envB;
501
+ const cmd = `clef set ${ns}/${missingEnv} ${row.key}`;
502
+ return (
503
+ <div
504
+ key={row.key}
505
+ style={{
506
+ display: "flex",
507
+ alignItems: "center",
508
+ gap: 12,
509
+ }}
510
+ >
511
+ <span style={{ fontSize: 16 }}>{"\uD83D\uDCA1"}</span>
512
+ <span
513
+ style={{
514
+ fontFamily: theme.sans,
515
+ fontSize: 12,
516
+ color: theme.textMuted,
517
+ flex: 1,
518
+ }}
519
+ >
520
+ <strong style={{ color: theme.text }}>{row.key}</strong> is missing in{" "}
521
+ <EnvBadge env={missingEnv} small />. Run{" "}
522
+ <code
523
+ style={{
524
+ fontFamily: theme.mono,
525
+ fontSize: 11,
526
+ color: theme.accent,
527
+ background: theme.accentDim,
528
+ padding: "1px 6px",
529
+ borderRadius: 3,
530
+ }}
531
+ >
532
+ {cmd}
533
+ </code>{" "}
534
+ to add it.
535
+ </span>
536
+ <CopyButton text={cmd} />
537
+ </div>
538
+ );
539
+ })}
540
+ </div>
541
+ )}
542
+ </>
543
+ )}
544
+ </div>
545
+ </div>
546
+ );
547
+ }