@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
@@ -0,0 +1,237 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { SchemaEditor } from "./SchemaEditor";
5
+ import type { ClefManifest } 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: "prod", description: "Prod", protected: true },
15
+ ],
16
+ namespaces: [{ name: "auth", description: "Auth" }],
17
+ sops: { default_backend: "age" },
18
+ file_pattern: "{namespace}/{environment}.enc.yaml",
19
+ };
20
+
21
+ interface FetchSpec {
22
+ schema?: { ok?: boolean; status?: number; body: unknown };
23
+ schemaPut?: { ok?: boolean; status?: number; body: unknown };
24
+ values?: { ok?: boolean; body: unknown };
25
+ }
26
+
27
+ function mockRoutes(spec: FetchSpec): jest.Mock {
28
+ const fetchMock = jest.fn(async (url: string, init?: RequestInit) => {
29
+ const method = init?.method ?? "GET";
30
+ if (url.match(/\/api\/namespaces\/[^/]+\/schema$/) && method === "GET") {
31
+ const s = spec.schema ?? {
32
+ ok: true,
33
+ body: { attached: false, path: null, schema: { keys: {} } },
34
+ };
35
+ return {
36
+ ok: s.ok !== false,
37
+ status: s.status ?? 200,
38
+ json: () => Promise.resolve(s.body),
39
+ } as Response;
40
+ }
41
+ if (url.match(/\/api\/namespaces\/[^/]+\/schema$/) && method === "PUT") {
42
+ const s = spec.schemaPut ?? {
43
+ ok: true,
44
+ body: { attached: true, path: "schemas/auth.yaml", schema: { keys: {} } },
45
+ };
46
+ return {
47
+ ok: s.ok !== false,
48
+ status: s.status ?? 200,
49
+ json: () => Promise.resolve(s.body),
50
+ } as Response;
51
+ }
52
+ if (url.match(/\/api\/namespace\/[^/]+\/[^/]+$/) && method === "GET") {
53
+ const s = spec.values ?? { ok: true, body: { values: {} } };
54
+ return {
55
+ ok: s.ok !== false,
56
+ status: 200,
57
+ json: () => Promise.resolve(s.body),
58
+ } as Response;
59
+ }
60
+ throw new Error(`Unmocked fetch: ${method} ${url}`);
61
+ });
62
+ global.fetch = fetchMock;
63
+ return fetchMock;
64
+ }
65
+
66
+ beforeEach(() => {
67
+ jest.clearAllMocks();
68
+ jest.restoreAllMocks();
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ delete (global as any).fetch;
71
+ });
72
+
73
+ describe("SchemaEditor", () => {
74
+ it("loads an attached schema and renders one row per key", async () => {
75
+ mockRoutes({
76
+ schema: {
77
+ ok: true,
78
+ body: {
79
+ attached: true,
80
+ path: "schemas/auth.yaml",
81
+ schema: {
82
+ keys: {
83
+ API_KEY: { type: "string", required: true, pattern: "^sk_" },
84
+ FLAG: { type: "boolean", required: false },
85
+ },
86
+ },
87
+ },
88
+ },
89
+ });
90
+
91
+ await act(async () => {
92
+ render(<SchemaEditor ns="auth" manifest={manifest} />);
93
+ });
94
+
95
+ expect(screen.getByDisplayValue("API_KEY")).toBeInTheDocument();
96
+ expect(screen.getByDisplayValue("FLAG")).toBeInTheDocument();
97
+ expect(screen.getByDisplayValue("^sk_")).toBeInTheDocument();
98
+ });
99
+
100
+ it("shows the empty-state hint when the namespace has no schema yet", async () => {
101
+ mockRoutes({});
102
+ await act(async () => {
103
+ render(<SchemaEditor ns="auth" manifest={manifest} />);
104
+ });
105
+ expect(screen.getByText(/No keys declared yet/i)).toBeInTheDocument();
106
+ // TopBar subtitle promises auto-create on save
107
+ expect(screen.getByText(/saving will create one at schemas\/auth\.yaml/i)).toBeInTheDocument();
108
+ });
109
+
110
+ it("adds a new key row when '+ Add key' is clicked", async () => {
111
+ mockRoutes({});
112
+ await act(async () => {
113
+ render(<SchemaEditor ns="auth" manifest={manifest} />);
114
+ });
115
+ fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
116
+ expect(screen.getByPlaceholderText("KEY_NAME")).toBeInTheDocument();
117
+ });
118
+
119
+ it("validates regex patterns inline against the preview env's sample value", async () => {
120
+ mockRoutes({
121
+ schema: {
122
+ ok: true,
123
+ body: {
124
+ attached: true,
125
+ path: "schemas/auth.yaml",
126
+ schema: { keys: { API_KEY: { type: "string", required: true, pattern: "^sk_" } } },
127
+ },
128
+ },
129
+ values: { ok: true, body: { values: { API_KEY: "sk_test_abc" } } },
130
+ });
131
+ await act(async () => {
132
+ render(<SchemaEditor ns="auth" manifest={manifest} />);
133
+ });
134
+ await waitFor(() => {
135
+ expect(screen.getByText(/matches sample value/)).toBeInTheDocument();
136
+ });
137
+ });
138
+
139
+ it("shows a miss when the pattern doesn't match the sample, and never echoes the value", async () => {
140
+ mockRoutes({
141
+ schema: {
142
+ ok: true,
143
+ body: {
144
+ attached: true,
145
+ path: "schemas/auth.yaml",
146
+ schema: { keys: { API_KEY: { type: "string", required: true, pattern: "^pk_" } } },
147
+ },
148
+ },
149
+ values: { ok: true, body: { values: { API_KEY: "sk_test_abc" } } },
150
+ });
151
+ await act(async () => {
152
+ render(<SchemaEditor ns="auth" manifest={manifest} />);
153
+ });
154
+ await waitFor(() => {
155
+ expect(screen.getByText(/did not match/)).toBeInTheDocument();
156
+ });
157
+ // The decrypted sample value must not appear anywhere in the rendered
158
+ // result — only the binary match/miss outcome is leaked.
159
+ expect(screen.queryByText(/sk_test_abc/)).toBeNull();
160
+ });
161
+
162
+ it("flags an invalid regex as a row error and disables Save", async () => {
163
+ mockRoutes({});
164
+ await act(async () => {
165
+ render(<SchemaEditor ns="auth" manifest={manifest} />);
166
+ });
167
+ fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
168
+ fireEvent.change(screen.getByPlaceholderText("KEY_NAME"), { target: { value: "K" } });
169
+ fireEvent.change(screen.getByPlaceholderText(/pattern: \^regex/), {
170
+ target: { value: "([" },
171
+ });
172
+ expect(screen.getByText(/not a valid regex/i)).toBeInTheDocument();
173
+ expect((screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe(true);
174
+ });
175
+
176
+ it("flags duplicate key names", async () => {
177
+ mockRoutes({});
178
+ await act(async () => {
179
+ render(<SchemaEditor ns="auth" manifest={manifest} />);
180
+ });
181
+ fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
182
+ fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
183
+ const inputs = screen.getAllByPlaceholderText("KEY_NAME");
184
+ fireEvent.change(inputs[0], { target: { value: "DUPE" } });
185
+ fireEvent.change(inputs[1], { target: { value: "DUPE" } });
186
+ expect(screen.getByText(/Duplicate key name/i)).toBeInTheDocument();
187
+ });
188
+
189
+ it("PUTs the schema on Save and shows a confirmation toast with the saved path", async () => {
190
+ const fetchMock = mockRoutes({
191
+ schemaPut: {
192
+ ok: true,
193
+ body: {
194
+ attached: true,
195
+ path: "schemas/auth.yaml",
196
+ schema: { keys: { API_KEY: { type: "string", required: true } } },
197
+ },
198
+ },
199
+ });
200
+ await act(async () => {
201
+ render(<SchemaEditor ns="auth" manifest={manifest} />);
202
+ });
203
+ fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
204
+ fireEvent.change(screen.getByPlaceholderText("KEY_NAME"), { target: { value: "API_KEY" } });
205
+
206
+ await act(async () => {
207
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
208
+ });
209
+
210
+ const putCall = fetchMock.mock.calls.find(
211
+ (c) => c[0].match(/\/api\/namespaces\/auth\/schema$/) && c[1]?.method === "PUT",
212
+ );
213
+ expect(putCall).toBeDefined();
214
+ const body = JSON.parse(putCall![1].body as string);
215
+ expect(body.schema.keys.API_KEY).toMatchObject({ type: "string", required: true });
216
+ await waitFor(() => {
217
+ expect(screen.getByText(/Saved at .* · schemas\/auth\.yaml/)).toBeInTheDocument();
218
+ });
219
+ });
220
+
221
+ it("surfaces a server-side error from the PUT", async () => {
222
+ mockRoutes({
223
+ schemaPut: { ok: false, status: 400, body: { error: "Boom", code: "INVALID_SCHEMA" } },
224
+ });
225
+ await act(async () => {
226
+ render(<SchemaEditor ns="auth" manifest={manifest} />);
227
+ });
228
+ fireEvent.click(screen.getByRole("button", { name: "+ Add key" }));
229
+ fireEvent.change(screen.getByPlaceholderText("KEY_NAME"), { target: { value: "K" } });
230
+
231
+ await act(async () => {
232
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
233
+ });
234
+
235
+ expect(screen.getByText("Boom")).toBeInTheDocument();
236
+ });
237
+ });
@@ -0,0 +1,435 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { apiFetch } from "../api";
3
+ import { Button } from "../components/Button";
4
+ import { Toolbar, EmptyState } from "../primitives";
5
+ import type { ClefManifest, NamespaceSchema, SchemaKey } from "@clef-sh/core";
6
+
7
+ interface SchemaEditorProps {
8
+ ns: string;
9
+ manifest: ClefManifest | null;
10
+ }
11
+
12
+ interface KeyRow {
13
+ /** Original name when the row came from disk; null for newly added rows. */
14
+ originalName: string | null;
15
+ /** Editable name field. */
16
+ name: string;
17
+ type: "string" | "integer" | "boolean";
18
+ required: boolean;
19
+ pattern: string;
20
+ description: string;
21
+ }
22
+
23
+ interface SchemaResponse {
24
+ namespace: string;
25
+ attached: boolean;
26
+ path: string | null;
27
+ schema: NamespaceSchema;
28
+ }
29
+
30
+ const TYPES: KeyRow["type"][] = ["string", "integer", "boolean"];
31
+
32
+ const INPUT_BASE =
33
+ "rounded border border-edge bg-ink-950 px-2 py-1 font-sans text-[12px] text-bone outline-none focus-visible:border-gold-500 placeholder:text-ash-dim disabled:text-ash";
34
+ const INPUT_MONO =
35
+ "rounded border border-edge bg-ink-950 px-2 py-1 font-mono text-[12px] text-bone outline-none focus-visible:border-gold-500 placeholder:text-ash-dim disabled:text-ash";
36
+
37
+ export function SchemaEditor({ ns, manifest }: SchemaEditorProps) {
38
+ const [rows, setRows] = useState<KeyRow[]>([]);
39
+ const [attached, setAttached] = useState(false);
40
+ const [pathOnDisk, setPathOnDisk] = useState<string | null>(null);
41
+ const [previewEnv, setPreviewEnv] = useState<string>("");
42
+ const [sampleValues, setSampleValues] = useState<Record<string, string>>({});
43
+ const [loading, setLoading] = useState(false);
44
+ const [saving, setSaving] = useState(false);
45
+ const [error, setError] = useState<string | null>(null);
46
+ const [savedAt, setSavedAt] = useState<string | null>(null);
47
+ const [savedPath, setSavedPath] = useState<string | null>(null);
48
+
49
+ const environments = manifest?.environments ?? [];
50
+
51
+ useEffect(() => {
52
+ if (!previewEnv && environments.length > 0) {
53
+ const firstUnprotected = environments.find((e) => !e.protected) ?? environments[0];
54
+ setPreviewEnv(firstUnprotected.name);
55
+ }
56
+ }, [environments, previewEnv]);
57
+
58
+ const loadSchema = useCallback(async () => {
59
+ if (!ns) return;
60
+ setLoading(true);
61
+ setError(null);
62
+ try {
63
+ const res = await apiFetch(`/api/namespaces/${ns}/schema`);
64
+ const body = await res.json();
65
+ if (!res.ok) {
66
+ setError(body.error || "Failed to load schema");
67
+ setRows([]);
68
+ return;
69
+ }
70
+ const data = body as SchemaResponse;
71
+ setAttached(data.attached);
72
+ setPathOnDisk(data.path);
73
+ setRows(schemaToRows(data.schema));
74
+ } catch {
75
+ setError("Failed to load schema");
76
+ } finally {
77
+ setLoading(false);
78
+ }
79
+ }, [ns]);
80
+
81
+ const loadSampleValues = useCallback(async () => {
82
+ if (!ns || !previewEnv) return;
83
+ try {
84
+ const res = await apiFetch(`/api/namespace/${ns}/${previewEnv}`);
85
+ if (!res.ok) {
86
+ setSampleValues({});
87
+ return;
88
+ }
89
+ const data = (await res.json()) as { values: Record<string, string> };
90
+ setSampleValues(data.values ?? {});
91
+ } catch {
92
+ setSampleValues({});
93
+ }
94
+ }, [ns, previewEnv]);
95
+
96
+ useEffect(() => {
97
+ loadSchema();
98
+ }, [loadSchema]);
99
+
100
+ useEffect(() => {
101
+ loadSampleValues();
102
+ }, [loadSampleValues]);
103
+
104
+ const handleSave = async () => {
105
+ setSaving(true);
106
+ setError(null);
107
+ setSavedAt(null);
108
+ try {
109
+ const schema = rowsToSchema(rows);
110
+ const res = await apiFetch(`/api/namespaces/${ns}/schema`, {
111
+ method: "PUT",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ schema }),
114
+ });
115
+ const body = await res.json();
116
+ if (!res.ok) {
117
+ setError(body.error || "Failed to save schema");
118
+ return;
119
+ }
120
+ const data = body as SchemaResponse;
121
+ setAttached(data.attached);
122
+ setPathOnDisk(data.path);
123
+ setSavedPath(data.path);
124
+ setSavedAt(new Date().toLocaleTimeString());
125
+ // Refresh from server so any normalisation (e.g. trimmed empty fields)
126
+ // shows up in the editor and "edited" markers reset.
127
+ setRows(schemaToRows(data.schema));
128
+ } catch (err) {
129
+ setError(err instanceof Error ? err.message : "Failed to save schema");
130
+ } finally {
131
+ setSaving(false);
132
+ }
133
+ };
134
+
135
+ const handleAddRow = () => {
136
+ setRows((prev) => [
137
+ ...prev,
138
+ {
139
+ originalName: null,
140
+ name: "",
141
+ type: "string",
142
+ required: true,
143
+ pattern: "",
144
+ description: "",
145
+ },
146
+ ]);
147
+ };
148
+
149
+ const handleRemoveRow = (idx: number) => {
150
+ setRows((prev) => prev.filter((_, i) => i !== idx));
151
+ };
152
+
153
+ const updateRow = (idx: number, patch: Partial<KeyRow>) => {
154
+ setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
155
+ };
156
+
157
+ const validation = useMemo(() => validateRows(rows), [rows]);
158
+
159
+ return (
160
+ <div className="flex h-full flex-col">
161
+ <Toolbar>
162
+ <div>
163
+ <Toolbar.Title>{`Schema · ${ns || "(no namespace selected)"}`}</Toolbar.Title>
164
+ <Toolbar.Subtitle>
165
+ {attached && pathOnDisk
166
+ ? pathOnDisk
167
+ : ns
168
+ ? `no schema attached yet — saving will create one at schemas/${ns}.yaml`
169
+ : ""}
170
+ </Toolbar.Subtitle>
171
+ </div>
172
+ <Toolbar.Actions>
173
+ <Button onClick={handleAddRow} disabled={!ns || loading}>
174
+ + Add key
175
+ </Button>
176
+ <Button variant="primary" onClick={handleSave} disabled={!ns || saving || !validation.ok}>
177
+ {saving ? "Saving…" : "Save"}
178
+ </Button>
179
+ </Toolbar.Actions>
180
+ </Toolbar>
181
+
182
+ <div className="flex-1 overflow-auto px-6 py-4 font-sans text-bone">
183
+ {error && (
184
+ <div className="mb-3 rounded-md border border-stop-500/30 bg-stop-500/10 px-3 py-2 text-[12px] text-stop-500">
185
+ {error}
186
+ </div>
187
+ )}
188
+
189
+ {savedAt && savedPath && !error && (
190
+ <div className="mb-3 rounded-md border border-edge bg-ink-850 px-3 py-2 text-[12px] text-ash">
191
+ Saved at {savedAt} · {savedPath}
192
+ </div>
193
+ )}
194
+
195
+ {loading && <div className="text-[12px] text-ash">Loading…</div>}
196
+
197
+ {!loading && rows.length === 0 && (
198
+ <EmptyState
199
+ title="No keys declared yet"
200
+ body="Click + Add key to start building the schema."
201
+ />
202
+ )}
203
+
204
+ {!loading && rows.length > 0 && (
205
+ <div className="flex flex-col gap-2">
206
+ <PreviewEnvPicker
207
+ environments={environments.map((e) => e.name)}
208
+ value={previewEnv}
209
+ onChange={setPreviewEnv}
210
+ />
211
+ {rows.map((row, idx) => (
212
+ <SchemaRow
213
+ key={`${row.originalName ?? "new"}-${idx}`}
214
+ row={row}
215
+ error={validation.rowErrors[idx]}
216
+ sampleValue={row.name ? sampleValues[row.name] : undefined}
217
+ onChange={(patch) => updateRow(idx, patch)}
218
+ onRemove={() => handleRemoveRow(idx)}
219
+ />
220
+ ))}
221
+ </div>
222
+ )}
223
+ </div>
224
+ </div>
225
+ );
226
+ }
227
+
228
+ function PreviewEnvPicker(props: {
229
+ environments: string[];
230
+ value: string;
231
+ onChange: (env: string) => void;
232
+ }) {
233
+ if (props.environments.length === 0) return null;
234
+ return (
235
+ <div className="mb-1 flex items-center gap-2 text-[11px] text-ash">
236
+ <span>Pattern preview against:</span>
237
+ <select
238
+ value={props.value}
239
+ onChange={(e) => props.onChange(e.target.value)}
240
+ className="rounded border border-edge bg-ink-850 px-1.5 py-0.5 font-mono text-[11px] text-bone"
241
+ >
242
+ {props.environments.map((e) => (
243
+ <option key={e} value={e}>
244
+ {e}
245
+ </option>
246
+ ))}
247
+ </select>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ function SchemaRow(props: {
253
+ row: KeyRow;
254
+ error?: string;
255
+ sampleValue: string | undefined;
256
+ onChange: (patch: Partial<KeyRow>) => void;
257
+ onRemove: () => void;
258
+ }) {
259
+ const { row, error, sampleValue, onChange, onRemove } = props;
260
+ const patternMatchState = patternMatch(row.pattern, sampleValue);
261
+
262
+ // Brief "checking" state on every pattern/sample change so the user sees
263
+ // the test get re-run rather than the result just silently changing.
264
+ const [checking, setChecking] = useState(false);
265
+ useEffect(() => {
266
+ if (!row.pattern || row.type !== "string") return;
267
+ setChecking(true);
268
+ const t = setTimeout(() => setChecking(false), 180);
269
+ return () => clearTimeout(t);
270
+ }, [row.pattern, sampleValue, row.type]);
271
+
272
+ const previewToneClass = checking
273
+ ? "text-ash"
274
+ : patternMatchState === "match"
275
+ ? "text-go-500"
276
+ : patternMatchState === "miss"
277
+ ? "text-stop-500"
278
+ : "text-ash";
279
+
280
+ return (
281
+ <div
282
+ className={`grid grid-cols-[minmax(140px,1fr)_110px_auto_1fr_auto] items-center gap-x-2 gap-y-2 rounded-md border bg-ink-850 p-3 ${
283
+ error ? "border-stop-500" : "border-edge"
284
+ }`}
285
+ >
286
+ <input
287
+ placeholder="KEY_NAME"
288
+ value={row.name}
289
+ onChange={(e) => onChange({ name: e.target.value })}
290
+ className={INPUT_MONO}
291
+ />
292
+ <select
293
+ value={row.type}
294
+ onChange={(e) => onChange({ type: e.target.value as KeyRow["type"] })}
295
+ className={INPUT_BASE}
296
+ >
297
+ {TYPES.map((t) => (
298
+ <option key={t} value={t}>
299
+ {t}
300
+ </option>
301
+ ))}
302
+ </select>
303
+ <label className="flex items-center gap-1.5 text-[11px] text-bone">
304
+ <input
305
+ type="checkbox"
306
+ checked={row.required}
307
+ onChange={(e) => onChange({ required: e.target.checked })}
308
+ className="accent-gold-500"
309
+ />
310
+ required
311
+ </label>
312
+ <input
313
+ placeholder={row.type === "string" ? "pattern: ^regex$ (optional)" : "— (strings only)"}
314
+ value={row.pattern}
315
+ disabled={row.type !== "string"}
316
+ onChange={(e) => onChange({ pattern: e.target.value })}
317
+ className={`${INPUT_MONO} ${row.type !== "string" ? "opacity-60" : ""}`}
318
+ />
319
+ <Button variant="danger" onClick={onRemove}>
320
+ Remove
321
+ </Button>
322
+
323
+ <input
324
+ placeholder="description (optional)"
325
+ value={row.description}
326
+ onChange={(e) => onChange({ description: e.target.value })}
327
+ className={`${INPUT_BASE} col-span-5`}
328
+ />
329
+
330
+ {error && <div className="col-span-5 text-[11px] text-stop-500">{error}</div>}
331
+
332
+ {row.type === "string" && row.pattern && (
333
+ <div
334
+ className={`col-span-5 flex items-center gap-2 font-mono text-[11px] ${previewToneClass}`}
335
+ >
336
+ {checking ? (
337
+ <>
338
+ <Spinner />
339
+ <span>testing…</span>
340
+ </>
341
+ ) : sampleValue === undefined ? (
342
+ <span>No sample value in the selected env.</span>
343
+ ) : patternMatchState === "invalid" ? (
344
+ <span>Invalid regex.</span>
345
+ ) : patternMatchState === "match" ? (
346
+ <span data-testid="pattern-result">✔ matches sample value</span>
347
+ ) : (
348
+ <span data-testid="pattern-result">✖ did not match</span>
349
+ )}
350
+ </div>
351
+ )}
352
+ </div>
353
+ );
354
+ }
355
+
356
+ function Spinner() {
357
+ // Keyframe `clef-schema-spin` is provided globally from styles.css so
358
+ // we don't inject a <style> tag at module load anymore.
359
+ return (
360
+ <span
361
+ aria-hidden="true"
362
+ className="inline-block h-2.5 w-2.5 rounded-full border-[1.5px] border-edge border-t-bone animate-[clef-schema-spin_0.6s_linear_infinite]"
363
+ />
364
+ );
365
+ }
366
+
367
+ function patternMatch(
368
+ pattern: string,
369
+ sample: string | undefined,
370
+ ): "match" | "miss" | "invalid" | "no-sample" {
371
+ if (!pattern) return "no-sample";
372
+ if (sample === undefined) return "no-sample";
373
+ let re: RegExp;
374
+ try {
375
+ re = new RegExp(pattern);
376
+ } catch {
377
+ return "invalid";
378
+ }
379
+ return re.test(sample) ? "match" : "miss";
380
+ }
381
+
382
+ function schemaToRows(schema: NamespaceSchema): KeyRow[] {
383
+ return Object.entries(schema.keys).map(([name, def]) => ({
384
+ originalName: name,
385
+ name,
386
+ type: def.type,
387
+ required: def.required,
388
+ pattern: def.pattern ?? "",
389
+ description: def.description ?? "",
390
+ }));
391
+ }
392
+
393
+ function rowsToSchema(rows: KeyRow[]): NamespaceSchema {
394
+ const keys: Record<string, SchemaKey> = {};
395
+ for (const row of rows) {
396
+ if (!row.name) continue; // empty-name rows are filtered (validation flags them separately)
397
+ keys[row.name] = {
398
+ type: row.type,
399
+ required: row.required,
400
+ ...(row.pattern && row.type === "string" ? { pattern: row.pattern } : {}),
401
+ ...(row.description ? { description: row.description } : {}),
402
+ };
403
+ }
404
+ return { keys };
405
+ }
406
+
407
+ function validateRows(rows: KeyRow[]): { ok: boolean; rowErrors: Record<number, string> } {
408
+ const rowErrors: Record<number, string> = {};
409
+ const seenNames = new Map<string, number>();
410
+ rows.forEach((row, idx) => {
411
+ if (!row.name.trim()) {
412
+ rowErrors[idx] = "Key name is required.";
413
+ return;
414
+ }
415
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(row.name)) {
416
+ rowErrors[idx] =
417
+ "Key name must start with a letter or underscore and contain only letters, digits, and underscores.";
418
+ return;
419
+ }
420
+ const seen = seenNames.get(row.name);
421
+ if (seen !== undefined) {
422
+ rowErrors[idx] = `Duplicate key name (also row ${seen + 1}).`;
423
+ return;
424
+ }
425
+ seenNames.set(row.name, idx);
426
+ if (row.type === "string" && row.pattern) {
427
+ try {
428
+ new RegExp(row.pattern);
429
+ } catch (err) {
430
+ rowErrors[idx] = `Pattern is not a valid regex: ${(err as Error).message}.`;
431
+ }
432
+ }
433
+ });
434
+ return { ok: Object.keys(rowErrors).length === 0, rowErrors };
435
+ }