@clef-sh/ui 0.1.20 → 0.1.21

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 (103) hide show
  1. package/dist/client/assets/index-DPWHjBbB.js +34 -0
  2. package/dist/client/assets/index-qsLTYpc9.css +2 -0
  3. package/dist/client/clef.svg +2 -0
  4. package/dist/client/index.html +3 -31
  5. package/dist/client-lib/components/Button.d.ts +1 -1
  6. package/dist/client-lib/components/Button.d.ts.map +1 -1
  7. package/dist/client-lib/components/CopyButton.d.ts.map +1 -1
  8. package/dist/client-lib/components/EnvBadge.d.ts.map +1 -1
  9. package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -1
  10. package/dist/client-lib/components/Sidebar.d.ts +1 -1
  11. package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
  12. package/dist/client-lib/components/StatusDot.d.ts.map +1 -1
  13. package/dist/client-lib/components/SyncPanel.d.ts.map +1 -1
  14. package/dist/client-lib/components/TopBar.d.ts +6 -0
  15. package/dist/client-lib/components/TopBar.d.ts.map +1 -1
  16. package/dist/client-lib/primitives/Badge.d.ts +11 -0
  17. package/dist/client-lib/primitives/Badge.d.ts.map +1 -0
  18. package/dist/client-lib/primitives/Card.d.ts +28 -0
  19. package/dist/client-lib/primitives/Card.d.ts.map +1 -0
  20. package/dist/client-lib/primitives/Dialog.d.ts +30 -0
  21. package/dist/client-lib/primitives/Dialog.d.ts.map +1 -0
  22. package/dist/client-lib/primitives/EmptyState.d.ts +10 -0
  23. package/dist/client-lib/primitives/EmptyState.d.ts.map +1 -0
  24. package/dist/client-lib/primitives/Field.d.ts +36 -0
  25. package/dist/client-lib/primitives/Field.d.ts.map +1 -0
  26. package/dist/client-lib/primitives/Input.d.ts +6 -0
  27. package/dist/client-lib/primitives/Input.d.ts.map +1 -0
  28. package/dist/client-lib/primitives/Stat.d.ts +11 -0
  29. package/dist/client-lib/primitives/Stat.d.ts.map +1 -0
  30. package/dist/client-lib/primitives/Table.d.ts +37 -0
  31. package/dist/client-lib/primitives/Table.d.ts.map +1 -0
  32. package/dist/client-lib/primitives/Tabs.d.ts +29 -0
  33. package/dist/client-lib/primitives/Tabs.d.ts.map +1 -0
  34. package/dist/client-lib/primitives/Toast.d.ts +16 -0
  35. package/dist/client-lib/primitives/Toast.d.ts.map +1 -0
  36. package/dist/client-lib/primitives/Toolbar.d.ts +29 -0
  37. package/dist/client-lib/primitives/Toolbar.d.ts.map +1 -0
  38. package/dist/client-lib/primitives/index.d.ts +23 -0
  39. package/dist/client-lib/primitives/index.d.ts.map +1 -0
  40. package/dist/client-lib/theme.d.ts +18 -41
  41. package/dist/client-lib/theme.d.ts.map +1 -1
  42. package/dist/server/api.d.ts.map +1 -1
  43. package/dist/server/api.js +215 -0
  44. package/dist/server/api.js.map +1 -1
  45. package/dist/server/envelope.d.ts +15 -0
  46. package/dist/server/envelope.d.ts.map +1 -0
  47. package/dist/server/envelope.js +310 -0
  48. package/dist/server/envelope.js.map +1 -0
  49. package/package.json +7 -2
  50. package/src/client/App.tsx +16 -41
  51. package/src/client/components/Button.tsx +13 -22
  52. package/src/client/components/CopyButton.tsx +5 -12
  53. package/src/client/components/EnvBadge.tsx +30 -15
  54. package/src/client/components/MatrixGrid.tsx +108 -252
  55. package/src/client/components/Sidebar.tsx +123 -199
  56. package/src/client/components/StatusDot.tsx +10 -15
  57. package/src/client/components/SyncPanel.tsx +14 -62
  58. package/src/client/components/TopBar.tsx +11 -36
  59. package/src/client/index.html +1 -30
  60. package/src/client/main.tsx +1 -0
  61. package/src/client/primitives/Badge.test.tsx +47 -0
  62. package/src/client/primitives/Badge.tsx +64 -0
  63. package/src/client/primitives/Card.test.tsx +50 -0
  64. package/src/client/primitives/Card.tsx +85 -0
  65. package/src/client/primitives/Dialog.test.tsx +55 -0
  66. package/src/client/primitives/Dialog.tsx +96 -0
  67. package/src/client/primitives/EmptyState.test.tsx +25 -0
  68. package/src/client/primitives/EmptyState.tsx +38 -0
  69. package/src/client/primitives/Field.test.tsx +46 -0
  70. package/src/client/primitives/Field.tsx +95 -0
  71. package/src/client/primitives/Input.tsx +26 -0
  72. package/src/client/primitives/Stat.test.tsx +32 -0
  73. package/src/client/primitives/Stat.tsx +52 -0
  74. package/src/client/primitives/Table.test.tsx +58 -0
  75. package/src/client/primitives/Table.tsx +113 -0
  76. package/src/client/primitives/Tabs.test.tsx +44 -0
  77. package/src/client/primitives/Tabs.tsx +100 -0
  78. package/src/client/primitives/Toast.test.tsx +77 -0
  79. package/src/client/primitives/Toast.tsx +89 -0
  80. package/src/client/primitives/Toolbar.test.tsx +50 -0
  81. package/src/client/primitives/Toolbar.tsx +86 -0
  82. package/src/client/primitives/index.ts +43 -0
  83. package/src/client/public/clef.svg +2 -0
  84. package/src/client/screens/BackendScreen.tsx +104 -363
  85. package/src/client/screens/DiffView.tsx +187 -378
  86. package/src/client/screens/EnvelopeScreen.test.tsx +542 -0
  87. package/src/client/screens/EnvelopeScreen.tsx +948 -0
  88. package/src/client/screens/GitLogView.tsx +48 -106
  89. package/src/client/screens/ImportScreen.tsx +105 -308
  90. package/src/client/screens/LintView.tsx +184 -379
  91. package/src/client/screens/ManifestScreen.tsx +283 -445
  92. package/src/client/screens/MatrixView.tsx +75 -91
  93. package/src/client/screens/NamespaceEditor.tsx +234 -609
  94. package/src/client/screens/PolicyView.tsx +183 -453
  95. package/src/client/screens/RecipientsScreen.tsx +71 -350
  96. package/src/client/screens/ResetScreen.tsx +67 -237
  97. package/src/client/screens/ScanScreen.tsx +85 -249
  98. package/src/client/screens/SchemaEditor.test.tsx +237 -0
  99. package/src/client/screens/SchemaEditor.tsx +435 -0
  100. package/src/client/screens/ServiceIdentitiesScreen.tsx +251 -788
  101. package/src/client/styles.css +77 -0
  102. package/src/client/theme.ts +27 -48
  103. package/dist/client/assets/index-Db6WgHgY.js +0 -38
@@ -1,9 +1,8 @@
1
1
  import React, { useState, useEffect, useCallback, useMemo } from "react";
2
- import { theme, ENV_COLORS } from "../theme";
3
2
  import { apiFetch } from "../api";
4
- import { TopBar } from "../components/TopBar";
5
3
  import { Button } from "../components/Button";
6
4
  import { EnvBadge } from "../components/EnvBadge";
5
+ import { Toolbar, Card, EmptyState, Badge } from "../primitives";
7
6
  import type { ViewName } from "../components/Sidebar";
8
7
  import type { PolicyDocument, FileRotationStatus, KeyRotationStatus } from "@clef-sh/core";
9
8
 
@@ -28,25 +27,61 @@ type StatusFilter = "all" | "overdue" | "unknown" | "ok";
28
27
 
29
28
  const MS_PER_DAY = 86_400_000;
30
29
 
31
- const STATUS_META = {
32
- overdue: { color: theme.red, bg: theme.redDim, label: "Overdue", icon: "\u2715" },
33
- unknown: { color: theme.yellow, bg: theme.yellowDim, label: "Unknown", icon: "?" },
34
- ok: { color: theme.green, bg: theme.greenDim, label: "OK", icon: "\u2713" },
35
- } as const;
36
-
37
- /**
38
- * A flattened row — one per (file, key) pair. The PolicyView renders these
39
- * grouped by per-key status so users see the actual policy signal rather
40
- * than a file-level aggregate.
41
- */
30
+ interface StatusMeta {
31
+ label: string;
32
+ icon: string;
33
+ // Tailwind class fragments per status kept as strings so JIT picks them up.
34
+ textClass: string;
35
+ bgClass: string;
36
+ borderClass: string;
37
+ stripeClass: string;
38
+ badgeTone: "stop" | "warn" | "go";
39
+ }
40
+
41
+ const STATUS_META: Record<"overdue" | "unknown" | "ok", StatusMeta> = {
42
+ overdue: {
43
+ label: "Overdue",
44
+ icon: "✕",
45
+ textClass: "text-stop-500",
46
+ bgClass: "bg-stop-500/15",
47
+ borderClass: "border-stop-500/40",
48
+ stripeClass: "border-l-stop-500/40",
49
+ badgeTone: "stop",
50
+ },
51
+ unknown: {
52
+ label: "Unknown",
53
+ icon: "?",
54
+ textClass: "text-warn-500",
55
+ bgClass: "bg-warn-500/15",
56
+ borderClass: "border-warn-500/40",
57
+ stripeClass: "border-l-warn-500/40",
58
+ badgeTone: "warn",
59
+ },
60
+ ok: {
61
+ label: "OK",
62
+ icon: "✓",
63
+ textClass: "text-go-500",
64
+ bgClass: "bg-go-500/15",
65
+ borderClass: "border-go-500/40",
66
+ stripeClass: "border-l-go-500/40",
67
+ badgeTone: "go",
68
+ },
69
+ };
70
+
71
+ // Per-env override chip. Mirrors `EnvBadge` color choices so the policy's
72
+ // environment overrides read with the same colors as the matrix.
73
+ const ENV_OVERRIDE_CLASSES: Record<string, { text: string; bg: string; border: string }> = {
74
+ dev: { text: "text-go-500", bg: "bg-go-500/10", border: "border-go-500/20" },
75
+ staging: { text: "text-warn-500", bg: "bg-warn-500/10", border: "border-warn-500/20" },
76
+ production: { text: "text-stop-500", bg: "bg-stop-500/10", border: "border-stop-500/20" },
77
+ };
78
+
42
79
  interface KeyRow {
43
80
  key: KeyRotationStatus;
44
81
  file: FileRotationStatus;
45
82
  }
46
83
 
47
84
  function keyRowStatus(k: KeyRotationStatus): "overdue" | "unknown" | "ok" {
48
- // Unknown is checked before overdue because `rotation_overdue` is only
49
- // meaningful when `last_rotated_known` is true.
50
85
  if (!k.last_rotated_known) return "unknown";
51
86
  if (k.rotation_overdue) return "overdue";
52
87
  return "ok";
@@ -56,7 +91,6 @@ function ageInDays(iso: string): number {
56
91
  return Math.floor((Date.now() - new Date(iso).getTime()) / MS_PER_DAY);
57
92
  }
58
93
 
59
- /** Derive max_age_days for a key from its rotation_due vs last_rotated_at. */
60
94
  function keyLimitDays(k: KeyRotationStatus): number | null {
61
95
  if (!k.last_rotated_at || !k.rotation_due) return null;
62
96
  const due = new Date(k.rotation_due).getTime();
@@ -64,6 +98,43 @@ function keyLimitDays(k: KeyRotationStatus): number | null {
64
98
  return Math.round((due - last) / MS_PER_DAY);
65
99
  }
66
100
 
101
+ const FILTER_DEFINITIONS: ReadonlyArray<{
102
+ key: StatusFilter;
103
+ label: string;
104
+ textClass: string;
105
+ bgClass: string;
106
+ borderClass: string;
107
+ }> = [
108
+ {
109
+ key: "all",
110
+ label: "All keys",
111
+ textClass: "text-ash",
112
+ bgClass: "bg-ash/10",
113
+ borderClass: "border-ash/30",
114
+ },
115
+ {
116
+ key: "overdue",
117
+ label: "Overdue",
118
+ textClass: "text-stop-500",
119
+ bgClass: "bg-stop-500/15",
120
+ borderClass: "border-stop-500/40",
121
+ },
122
+ {
123
+ key: "unknown",
124
+ label: "Unknown",
125
+ textClass: "text-warn-500",
126
+ bgClass: "bg-warn-500/15",
127
+ borderClass: "border-warn-500/40",
128
+ },
129
+ {
130
+ key: "ok",
131
+ label: "Compliant",
132
+ textClass: "text-go-500",
133
+ bgClass: "bg-go-500/15",
134
+ borderClass: "border-go-500/40",
135
+ },
136
+ ];
137
+
67
138
  export function PolicyView({ setView, setNs }: PolicyViewProps) {
68
139
  const [data, setData] = useState<PolicyCheckResponse | null>(null);
69
140
  const [rawYaml, setRawYaml] = useState<string>("");
@@ -96,11 +167,7 @@ export function PolicyView({ setView, setNs }: PolicyViewProps) {
96
167
  loadPolicy();
97
168
  }, [loadPolicy]);
98
169
 
99
- // Extract the namespace from a cell path. The cell path is shaped like
100
- // `{prefix...}/<namespace>/<environment>.enc.yaml` per the manifest's
101
- // file_pattern — so the second-to-last segment is always the namespace,
102
- // regardless of how many leading directories the repo uses. Mirrors
103
- // LintView's handleNavigate.
170
+ // Extract namespace from cell path; mirrors LintView's handleNavigate.
104
171
  const namespaceFromPath = (filePath: string): string | undefined => {
105
172
  const parts = filePath.split("/");
106
173
  return parts.length >= 2 ? parts[parts.length - 2] : parts[0];
@@ -119,9 +186,6 @@ export function PolicyView({ setView, setNs }: PolicyViewProps) {
119
186
  const policy = data?.policy;
120
187
  const source = data?.source;
121
188
 
122
- // Flatten (file, key) pairs so we can group by per-key status. This is the
123
- // authoritative view of rotation compliance — unknown rotation state on a
124
- // single key fails the gate regardless of how many other keys are fresh.
125
189
  const allRows: KeyRow[] = useMemo(
126
190
  () => files.flatMap((f) => f.keys.map((k) => ({ file: f, key: k }))),
127
191
  [files],
@@ -148,87 +212,64 @@ export function PolicyView({ setView, setNs }: PolicyViewProps) {
148
212
  const allCompliant = counts.total > 0 && counts.overdue === 0 && counts.unknown === 0;
149
213
  const noFiles = !loading && files.length === 0;
150
214
 
215
+ const filterCounts: Record<StatusFilter, number> = {
216
+ all: counts.total,
217
+ overdue: counts.overdue,
218
+ unknown: counts.unknown,
219
+ ok: counts.ok,
220
+ };
221
+
151
222
  return (
152
- <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
153
- <TopBar
154
- title="Policy"
155
- subtitle={"clef policy check \u2014 rotation verdicts"}
156
- actions={<Button onClick={loadPolicy}>{"\u21BB"} Re-run</Button>}
157
- />
223
+ <div className="flex flex-1 flex-col overflow-hidden">
224
+ <Toolbar>
225
+ <div>
226
+ <Toolbar.Title>Policy</Toolbar.Title>
227
+ <Toolbar.Subtitle>{"clef policy check — rotation verdicts"}</Toolbar.Subtitle>
228
+ </div>
229
+ <Toolbar.Actions>
230
+ <Button onClick={loadPolicy}>{"↻"} Re-run</Button>
231
+ </Toolbar.Actions>
232
+ </Toolbar>
158
233
 
159
234
  {/* Policy summary card */}
160
235
  {policy && (
161
- <div
162
- style={{
163
- padding: "16px 24px",
164
- background: theme.surface,
165
- borderBottom: `1px solid ${theme.border}`,
166
- }}
167
- >
168
- <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
169
- <span
170
- style={{
171
- fontFamily: theme.sans,
172
- fontSize: 11,
173
- fontWeight: 600,
174
- color: theme.textDim,
175
- letterSpacing: "0.08em",
176
- textTransform: "uppercase",
177
- }}
178
- >
236
+ <div className="border-b border-edge bg-ink-850 px-6 py-4">
237
+ <div className="flex flex-wrap items-center gap-3">
238
+ <span className="font-sans text-[11px] font-semibold uppercase tracking-[0.08em] text-ash-dim">
179
239
  Default
180
240
  </span>
181
- <span
182
- style={{
183
- fontFamily: theme.mono,
184
- fontSize: 13,
185
- color: theme.text,
186
- }}
187
- >
188
- {policy.rotation?.max_age_days ?? "\u2014"}
189
- <span style={{ color: theme.textMuted, marginLeft: 2 }}>d</span>
241
+ <span className="font-mono text-[13px] text-bone">
242
+ {policy.rotation?.max_age_days ?? "—"}
243
+ <span className="ml-0.5 text-ash">d</span>
190
244
  </span>
191
245
 
192
246
  {policy.rotation?.environments &&
193
247
  Object.entries(policy.rotation.environments).map(([env, cfg]) => {
194
- const c = ENV_COLORS[env] ?? { color: theme.textMuted, bg: "transparent" };
248
+ const c = ENV_OVERRIDE_CLASSES[env] ?? {
249
+ text: "text-ash",
250
+ bg: "bg-transparent",
251
+ border: "border-ash/20",
252
+ };
195
253
  return (
196
254
  <span
197
255
  key={env}
198
- style={{
199
- display: "inline-flex",
200
- alignItems: "center",
201
- gap: 6,
202
- padding: "2px 8px",
203
- borderRadius: 4,
204
- background: c.bg,
205
- border: `1px solid ${c.color}33`,
206
- fontFamily: theme.mono,
207
- fontSize: 11,
208
- color: c.color,
209
- }}
256
+ className={`inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 font-mono text-[11px] ${c.text} ${c.bg} ${c.border}`}
210
257
  >
211
- <span style={{ fontWeight: 700, letterSpacing: "0.06em" }}>
212
- {env.toUpperCase()}
213
- </span>
258
+ <span className="font-bold tracking-[0.06em]">{env.toUpperCase()}</span>
214
259
  <span>{cfg.max_age_days}d</span>
215
260
  </span>
216
261
  );
217
262
  })}
218
263
 
219
- <div style={{ flex: 1 }} />
264
+ <div className="flex-1" />
220
265
 
221
266
  <span
222
267
  data-testid="policy-source"
223
- style={{
224
- fontFamily: theme.mono,
225
- fontSize: 10,
226
- color: source === "file" ? theme.green : theme.textMuted,
227
- background: source === "file" ? theme.greenDim : "transparent",
228
- border: `1px solid ${source === "file" ? `${theme.green}44` : theme.border}`,
229
- borderRadius: 3,
230
- padding: "2px 8px",
231
- }}
268
+ className={`rounded-sm border px-2 py-0.5 font-mono text-[10px] ${
269
+ source === "file"
270
+ ? "border-go-500/30 bg-go-500/10 text-go-500"
271
+ : "border-edge bg-transparent text-ash"
272
+ }`}
232
273
  >
233
274
  {source === "file" ? ".clef/policy.yaml" : "Built-in default"}
234
275
  </span>
@@ -237,16 +278,7 @@ export function PolicyView({ setView, setNs }: PolicyViewProps) {
237
278
  <button
238
279
  data-testid="toggle-yaml"
239
280
  onClick={() => setShowYaml((v) => !v)}
240
- style={{
241
- fontFamily: theme.sans,
242
- fontSize: 11,
243
- color: theme.accent,
244
- background: "transparent",
245
- border: `1px solid ${theme.accent}33`,
246
- borderRadius: 4,
247
- padding: "3px 9px",
248
- cursor: "pointer",
249
- }}
281
+ className="cursor-pointer rounded-md border border-gold-500/30 bg-transparent px-2 py-0.5 font-sans text-[11px] text-gold-500 hover:bg-gold-500/10"
250
282
  >
251
283
  {showYaml ? "Hide YAML" : "View YAML"}
252
284
  </button>
@@ -256,18 +288,7 @@ export function PolicyView({ setView, setNs }: PolicyViewProps) {
256
288
  {showYaml && rawYaml && (
257
289
  <pre
258
290
  data-testid="raw-yaml"
259
- style={{
260
- marginTop: 12,
261
- padding: "12px 14px",
262
- background: "#0D0F14",
263
- border: `1px solid ${theme.borderLight}`,
264
- borderRadius: 6,
265
- fontFamily: theme.mono,
266
- fontSize: 11,
267
- color: theme.text,
268
- overflow: "auto",
269
- maxHeight: 200,
270
- }}
291
+ className="mt-3 max-h-[200px] overflow-auto rounded-md border border-edge-strong bg-ink-800 px-3.5 py-3 font-mono text-[11px] text-bone"
271
292
  >
272
293
  {rawYaml}
273
294
  </pre>
@@ -275,209 +296,52 @@ export function PolicyView({ setView, setNs }: PolicyViewProps) {
275
296
  </div>
276
297
  )}
277
298
 
278
- {/* Summary chips — per-key counts, not per-file */}
299
+ {/* Summary chips — per-key counts */}
279
300
  {!loading && counts.total > 0 && (
280
- <div
281
- style={{
282
- padding: "14px 24px",
283
- background: "#0D0F14",
284
- borderBottom: `1px solid ${theme.border}`,
285
- display: "flex",
286
- alignItems: "center",
287
- gap: 10,
288
- flexWrap: "wrap",
289
- }}
290
- >
291
- {[
292
- {
293
- key: "all" as const,
294
- label: "All keys",
295
- count: counts.total,
296
- color: theme.textMuted,
297
- },
298
- {
299
- key: "overdue" as const,
300
- label: "Overdue",
301
- count: counts.overdue,
302
- color: theme.red,
303
- },
304
- {
305
- key: "unknown" as const,
306
- label: "Unknown",
307
- count: counts.unknown,
308
- color: theme.yellow,
309
- },
310
- {
311
- key: "ok" as const,
312
- label: "Compliant",
313
- count: counts.ok,
314
- color: theme.green,
315
- },
316
- ].map((f) => (
317
- <button
318
- key={f.key}
319
- data-testid={`filter-${f.key}`}
320
- onClick={() => setFilter(f.key)}
321
- style={{
322
- display: "flex",
323
- alignItems: "center",
324
- gap: 6,
325
- padding: "5px 12px",
326
- borderRadius: 20,
327
- cursor: "pointer",
328
- fontFamily: theme.sans,
329
- fontSize: 12,
330
- fontWeight: filter === f.key ? 600 : 400,
331
- color: filter === f.key ? f.color : theme.textMuted,
332
- background: filter === f.key ? `${f.color}18` : "transparent",
333
- border: `1px solid ${filter === f.key ? `${f.color}55` : theme.border}`,
334
- transition: "all 0.12s",
335
- }}
336
- >
337
- <span
338
- style={{
339
- fontFamily: theme.mono,
340
- fontSize: 11,
341
- fontWeight: 700,
342
- color: f.color,
343
- }}
301
+ <div className="flex flex-wrap items-center gap-2.5 border-b border-edge bg-ink-800 px-6 py-3.5">
302
+ {FILTER_DEFINITIONS.map((f) => {
303
+ const active = filter === f.key;
304
+ const count = filterCounts[f.key];
305
+ return (
306
+ <button
307
+ key={f.key}
308
+ data-testid={`filter-${f.key}`}
309
+ onClick={() => setFilter(f.key)}
310
+ className={`flex cursor-pointer items-center gap-1.5 rounded-pill border px-3 py-1 font-sans text-[12px] transition-colors ${
311
+ active
312
+ ? `${f.textClass} ${f.bgClass} ${f.borderClass} font-semibold`
313
+ : "border-edge bg-transparent text-ash hover:text-bone"
314
+ }`}
344
315
  >
345
- {f.count}
346
- </span>
347
- {f.label}
348
- </button>
349
- ))}
316
+ <span className={`font-mono text-[11px] font-bold ${f.textClass}`}>{count}</span>
317
+ {f.label}
318
+ </button>
319
+ );
320
+ })}
350
321
  </div>
351
322
  )}
352
323
 
353
- <div style={{ flex: 1, overflow: "auto", padding: 24 }}>
354
- {loading && (
355
- <>
356
- <style>{`
357
- @keyframes clef-policy-line {
358
- 0% { transform: scaleX(0); opacity: 0; }
359
- 10% { opacity: 1; }
360
- 50% { transform: scaleX(1); opacity: 1; }
361
- 80% { transform: scaleX(1); opacity: 0.3; }
362
- 100% { transform: scaleX(0); opacity: 0; }
363
- }
364
- @keyframes clef-policy-glow {
365
- 0%, 100% { opacity: 0.4; }
366
- 50% { opacity: 1; }
367
- }
368
- `}</style>
369
- <div
370
- style={{
371
- display: "flex",
372
- alignItems: "center",
373
- justifyContent: "center",
374
- padding: "48px 24px",
375
- }}
376
- >
377
- <div
378
- style={{
379
- background: theme.surface,
380
- border: `1px solid ${theme.border}`,
381
- borderRadius: 10,
382
- padding: "28px 40px",
383
- textAlign: "center",
384
- minWidth: 200,
385
- }}
386
- >
387
- <div style={{ marginBottom: 16, display: "flex", flexDirection: "column", gap: 6 }}>
388
- {[0, 0.3, 0.6].map((delay, i) => (
389
- <div
390
- key={i}
391
- style={{
392
- height: 3,
393
- borderRadius: 2,
394
- background: theme.accent,
395
- transformOrigin: "left",
396
- animation: `clef-policy-line 1.8s ease-in-out ${delay}s infinite`,
397
- opacity: 0,
398
- width: [120, 90, 105][i],
399
- }}
400
- />
401
- ))}
402
- </div>
403
- <div
404
- style={{
405
- fontFamily: theme.mono,
406
- fontSize: 11,
407
- color: theme.textMuted,
408
- animation: "clef-policy-glow 1.8s ease-in-out infinite",
409
- }}
410
- >
411
- Evaluating policy...
412
- </div>
413
- </div>
414
- </div>
415
- </>
416
- )}
324
+ <div className="flex-1 overflow-auto p-6">
325
+ {loading && <EmptyState title="Evaluating policy..." />}
417
326
 
418
327
  {!loading && noFiles && (
419
- <div
328
+ <EmptyState
420
329
  data-testid="no-files"
421
- style={{
422
- display: "flex",
423
- flexDirection: "column",
424
- alignItems: "center",
425
- justifyContent: "center",
426
- gap: 14,
427
- padding: "60px 0",
428
- color: theme.textMuted,
429
- fontFamily: theme.sans,
430
- fontSize: 13,
431
- }}
432
- >
433
- No matrix files to evaluate.
434
- </div>
330
+ title="No matrix files to evaluate."
331
+ body="Add a namespace to your manifest to start tracking rotation policy."
332
+ />
435
333
  )}
436
334
 
437
335
  {!loading && allCompliant && (
438
336
  <div
439
337
  data-testid="all-compliant"
440
- style={{
441
- display: "flex",
442
- flexDirection: "column",
443
- alignItems: "center",
444
- justifyContent: "center",
445
- gap: 14,
446
- padding: "60px 0",
447
- }}
338
+ className="flex flex-col items-center justify-center gap-3.5 py-14"
448
339
  >
449
- <div
450
- style={{
451
- width: 56,
452
- height: 56,
453
- borderRadius: "50%",
454
- background: theme.greenDim,
455
- border: `1px solid ${theme.green}44`,
456
- display: "flex",
457
- alignItems: "center",
458
- justifyContent: "center",
459
- fontSize: 24,
460
- }}
461
- >
462
- {"\u2713"}
463
- </div>
464
- <div
465
- style={{
466
- fontFamily: theme.sans,
467
- fontWeight: 600,
468
- fontSize: 16,
469
- color: theme.green,
470
- }}
471
- >
472
- All compliant
340
+ <div className="flex h-14 w-14 items-center justify-center rounded-full border border-go-500/30 bg-go-500/15 text-[24px] text-go-500">
341
+ {"✓"}
473
342
  </div>
474
- <div
475
- style={{
476
- fontFamily: theme.mono,
477
- fontSize: 12,
478
- color: theme.textMuted,
479
- }}
480
- >
343
+ <div className="font-sans text-[16px] font-semibold text-go-500">All compliant</div>
344
+ <div className="font-mono text-[12px] text-ash">
481
345
  {counts.total} key{counts.total === 1 ? "" : "s"} within rotation window across{" "}
482
346
  {summary?.total_files ?? 0} file{summary?.total_files === 1 ? "" : "s"}
483
347
  </div>
@@ -496,75 +360,33 @@ export function PolicyView({ setView, setNs }: PolicyViewProps) {
496
360
  const meta = STATUS_META[status];
497
361
 
498
362
  return (
499
- <div key={status} style={{ marginBottom: 24 }}>
500
- <div
501
- style={{
502
- display: "flex",
503
- alignItems: "center",
504
- gap: 10,
505
- marginBottom: 10,
506
- }}
507
- >
363
+ <div key={status} className="mb-6">
364
+ <div className="mb-2.5 flex items-center gap-2.5">
508
365
  <div
509
- style={{
510
- width: 22,
511
- height: 22,
512
- borderRadius: "50%",
513
- background: meta.bg,
514
- border: `1px solid ${meta.color}44`,
515
- display: "flex",
516
- alignItems: "center",
517
- justifyContent: "center",
518
- fontFamily: theme.mono,
519
- fontSize: 11,
520
- fontWeight: 700,
521
- color: meta.color,
522
- }}
366
+ className={`flex h-[22px] w-[22px] items-center justify-center rounded-full border font-mono text-[11px] font-bold ${meta.borderClass} ${meta.bgClass} ${meta.textClass}`}
523
367
  >
524
368
  {meta.icon}
525
369
  </div>
526
- <span
527
- style={{
528
- fontFamily: theme.sans,
529
- fontWeight: 600,
530
- fontSize: 13,
531
- color: meta.color,
532
- }}
533
- >
370
+ <span className={`font-sans text-[13px] font-semibold ${meta.textClass}`}>
534
371
  {meta.label}
535
372
  </span>
536
373
  <span
537
- style={{
538
- fontFamily: theme.mono,
539
- fontSize: 10,
540
- color: meta.color,
541
- background: meta.bg,
542
- border: `1px solid ${meta.color}33`,
543
- borderRadius: 10,
544
- padding: "1px 8px",
545
- }}
374
+ className={`rounded-pill border px-2 py-px font-mono text-[10px] ${meta.borderClass} ${meta.bgClass} ${meta.textClass}`}
546
375
  >
547
376
  {group.length}
548
377
  </span>
549
378
  </div>
550
379
 
551
- <div
552
- style={{
553
- background: theme.surface,
554
- border: `1px solid ${theme.border}`,
555
- borderRadius: 10,
556
- overflow: "hidden",
557
- }}
558
- >
380
+ <Card className="overflow-hidden">
559
381
  {group.map((row, i) => {
560
382
  const { file, key } = row;
561
383
  const limit = keyLimitDays(key);
562
384
  const nsHint = namespaceFromPath(file.path) ?? "<namespace>";
563
385
  const message =
564
386
  status === "unknown"
565
- ? `No rotation record \u00B7 run clef set ${nsHint}/${file.environment} ${key.key} to establish`
387
+ ? `No rotation record · run clef set ${nsHint}/${file.environment} ${key.key} to establish`
566
388
  : key.last_rotated_at
567
- ? `Last rotated ${ageInDays(key.last_rotated_at)}d ago \u00B7 limit ${limit ?? "?"}d \u00B7 ${key.rotation_count} rotation${key.rotation_count === 1 ? "" : "s"}`
389
+ ? `Last rotated ${ageInDays(key.last_rotated_at)}d ago · limit ${limit ?? "?"}d · ${key.rotation_count} rotation${key.rotation_count === 1 ? "" : "s"}`
568
390
  : `Rotation state inconsistent`;
569
391
  const statusTag =
570
392
  status === "overdue" ? `${meta.label} ${key.days_overdue}d` : meta.label;
@@ -572,65 +394,25 @@ export function PolicyView({ setView, setNs }: PolicyViewProps) {
572
394
  return (
573
395
  <div
574
396
  key={`${file.path}-${key.key}-${i}`}
575
- style={{
576
- display: "flex",
577
- alignItems: "flex-start",
578
- borderBottom: i < group.length - 1 ? `1px solid ${theme.border}` : "none",
579
- borderLeft: `3px solid ${meta.color}66`,
580
- transition: "background 0.1s",
581
- padding: "14px 18px",
582
- gap: 14,
583
- }}
397
+ className={`flex items-start gap-3.5 border-l-[3px] px-4.5 py-3.5 transition-colors ${meta.stripeClass} ${
398
+ i < group.length - 1 ? "border-b border-edge" : ""
399
+ }`}
584
400
  >
585
- <div style={{ flexShrink: 0, paddingTop: 2 }}>
586
- <span
587
- style={{
588
- fontFamily: theme.mono,
589
- fontSize: 9,
590
- fontWeight: 700,
591
- color: meta.color,
592
- background: `${meta.color}18`,
593
- border: `1px solid ${meta.color}33`,
594
- borderRadius: 3,
595
- padding: "2px 6px",
596
- letterSpacing: "0.07em",
597
- textTransform: "uppercase",
598
- }}
599
- >
401
+ <div className="shrink-0 pt-0.5">
402
+ <Badge tone={meta.badgeTone} variant="solid">
600
403
  {statusTag}
601
- </span>
404
+ </Badge>
602
405
  </div>
603
406
 
604
- <div style={{ flex: 1, minWidth: 0 }}>
605
- <div
606
- style={{
607
- display: "flex",
608
- alignItems: "center",
609
- gap: 8,
610
- marginBottom: 4,
611
- flexWrap: "wrap",
612
- }}
613
- >
407
+ <div className="min-w-0 flex-1">
408
+ <div className="mb-1 flex flex-wrap items-center gap-2">
614
409
  <span
615
410
  data-testid={`key-ref-${key.key}`}
616
- style={{
617
- fontFamily: theme.mono,
618
- fontSize: 13,
619
- fontWeight: 700,
620
- color: theme.text,
621
- }}
411
+ className="font-mono text-[13px] font-bold text-bone"
622
412
  >
623
413
  {key.key}
624
414
  </span>
625
- <span
626
- style={{
627
- fontFamily: theme.mono,
628
- fontSize: 10,
629
- color: theme.textMuted,
630
- }}
631
- >
632
- {"\u2190"}
633
- </span>
415
+ <span className="font-mono text-[10px] text-ash">{"←"}</span>
634
416
  <span
635
417
  data-testid={`file-ref-${file.path}`}
636
418
  role="link"
@@ -639,86 +421,34 @@ export function PolicyView({ setView, setNs }: PolicyViewProps) {
639
421
  onKeyDown={(e) => {
640
422
  if (e.key === "Enter") handleNavigate(file);
641
423
  }}
642
- style={{
643
- fontFamily: theme.mono,
644
- fontSize: 11,
645
- fontWeight: 500,
646
- color: theme.accent,
647
- cursor: "pointer",
648
- textDecoration: "underline",
649
- textDecorationColor: `${theme.accent}55`,
650
- textDecorationStyle: "dotted",
651
- }}
424
+ className="cursor-pointer font-mono text-[11px] font-medium text-gold-500 underline decoration-gold-500/40 decoration-dotted"
652
425
  >
653
426
  {file.path}
654
427
  </span>
655
428
  <EnvBadge env={file.environment} small />
656
429
  </div>
657
430
 
658
- <div
659
- style={{
660
- fontFamily: theme.sans,
661
- fontSize: 12,
662
- color: theme.textMuted,
663
- }}
664
- >
665
- {message}
666
- </div>
431
+ <div className="font-sans text-[12px] text-ash">{message}</div>
667
432
  </div>
668
433
  </div>
669
434
  );
670
435
  })}
671
- </div>
436
+ </Card>
672
437
  </div>
673
438
  );
674
439
  })}
675
440
 
676
441
  {/* Footer hint */}
677
442
  {!loading && summary && summary.total_files > 0 && (
678
- <div
679
- style={{
680
- marginTop: 8,
681
- padding: "12px 16px",
682
- background: theme.surface,
683
- border: `1px solid ${theme.border}`,
684
- borderRadius: 8,
685
- display: "flex",
686
- alignItems: "center",
687
- gap: 12,
688
- }}
689
- >
690
- <span style={{ fontSize: 14 }}>{"\uD83D\uDCA1"}</span>
691
- <span
692
- style={{
693
- fontFamily: theme.sans,
694
- fontSize: 12,
695
- color: theme.textMuted,
696
- }}
697
- >
443
+ <div className="mt-2 flex items-center gap-3 rounded-md border border-edge bg-ink-850 px-4 py-3">
444
+ <span className="text-[14px]">{"💡"}</span>
445
+ <span className="font-sans text-[12px] text-ash">
698
446
  Edit{" "}
699
- <code
700
- style={{
701
- fontFamily: theme.mono,
702
- fontSize: 11,
703
- color: theme.accent,
704
- background: theme.accentDim,
705
- padding: "1px 6px",
706
- borderRadius: 3,
707
- }}
708
- >
447
+ <code className="rounded-sm bg-gold-500/10 px-1.5 py-px font-mono text-[11px] text-gold-500">
709
448
  .clef/policy.yaml
710
449
  </code>{" "}
711
450
  to change rotation limits. Run{" "}
712
- <code
713
- style={{
714
- fontFamily: theme.mono,
715
- fontSize: 11,
716
- color: theme.accent,
717
- background: theme.accentDim,
718
- padding: "1px 6px",
719
- borderRadius: 3,
720
- }}
721
- >
451
+ <code className="rounded-sm bg-gold-500/10 px-1.5 py-px font-mono text-[11px] text-gold-500">
722
452
  clef policy check
723
453
  </code>{" "}
724
454
  locally to reproduce this verdict.