@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,8 +1,7 @@
1
1
  import React, { useState, useCallback } from "react";
2
- import { theme } from "../theme";
3
2
  import { apiFetch } from "../api";
4
- import { TopBar } from "../components/TopBar";
5
3
  import { Button } from "../components/Button";
4
+ import { Toolbar, Dialog, Field, Input } from "../primitives";
6
5
  import type { ClefManifest, ClefNamespace, ClefEnvironment } from "@clef-sh/core";
7
6
 
8
7
  interface ManifestScreenProps {
@@ -38,8 +37,13 @@ export function ManifestScreen({ manifest, reloadManifest }: ManifestScreenProps
38
37
 
39
38
  if (!manifest) {
40
39
  return (
41
- <div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
42
- <TopBar title="Manifest" subtitle="Loading..." />
40
+ <div className="flex flex-1 flex-col">
41
+ <Toolbar>
42
+ <div>
43
+ <Toolbar.Title>Manifest</Toolbar.Title>
44
+ <Toolbar.Subtitle>Loading...</Toolbar.Subtitle>
45
+ </div>
46
+ </Toolbar>
43
47
  </div>
44
48
  );
45
49
  }
@@ -48,13 +52,17 @@ export function ManifestScreen({ manifest, reloadManifest }: ManifestScreenProps
48
52
  const environments = manifest.environments;
49
53
 
50
54
  return (
51
- <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
52
- <TopBar
53
- title="Manifest"
54
- subtitle={`${namespaces.length} namespaces \u00B7 ${environments.length} environments`}
55
- />
55
+ <div className="flex flex-1 flex-col overflow-hidden">
56
+ <Toolbar>
57
+ <div>
58
+ <Toolbar.Title>Manifest</Toolbar.Title>
59
+ <Toolbar.Subtitle>
60
+ {`${namespaces.length} namespaces · ${environments.length} environments`}
61
+ </Toolbar.Subtitle>
62
+ </div>
63
+ </Toolbar>
56
64
 
57
- <div style={{ flex: 1, overflow: "auto", padding: 28 }}>
65
+ <div className="flex-1 overflow-auto p-7">
58
66
  {/* Namespaces section */}
59
67
  <Section
60
68
  title="Namespaces"
@@ -63,7 +71,7 @@ export function ManifestScreen({ manifest, reloadManifest }: ManifestScreenProps
63
71
  actionTestId="add-namespace-btn"
64
72
  >
65
73
  {namespaces.length === 0 ? (
66
- <EmptyState message="No namespaces declared yet." />
74
+ <EmptyMessage message="No namespaces declared yet." />
67
75
  ) : (
68
76
  <EntityList>
69
77
  {namespaces.map((ns) => (
@@ -72,7 +80,7 @@ export function ManifestScreen({ manifest, reloadManifest }: ManifestScreenProps
72
80
  testId={`namespace-row-${ns.name}`}
73
81
  name={ns.name}
74
82
  description={ns.description}
75
- badges={ns.schema ? [{ label: `schema: ${ns.schema}`, color: theme.purple }] : []}
83
+ badges={ns.schema ? [{ label: `schema: ${ns.schema}`, tone: "purple" }] : []}
76
84
  onEdit={() => setModal({ kind: "editNamespace", ns })}
77
85
  onDelete={() => setModal({ kind: "removeNamespace", ns })}
78
86
  />
@@ -82,7 +90,7 @@ export function ManifestScreen({ manifest, reloadManifest }: ManifestScreenProps
82
90
  </Section>
83
91
 
84
92
  {/* Environments section */}
85
- <div style={{ marginTop: 36 }}>
93
+ <div className="mt-9">
86
94
  <Section
87
95
  title="Environments"
88
96
  actionLabel="+ Environment"
@@ -90,7 +98,7 @@ export function ManifestScreen({ manifest, reloadManifest }: ManifestScreenProps
90
98
  actionTestId="add-environment-btn"
91
99
  >
92
100
  {environments.length === 0 ? (
93
- <EmptyState message="No environments declared yet." />
101
+ <EmptyMessage message="No environments declared yet." />
94
102
  ) : (
95
103
  <EntityList>
96
104
  {environments.map((env) => (
@@ -99,7 +107,7 @@ export function ManifestScreen({ manifest, reloadManifest }: ManifestScreenProps
99
107
  testId={`environment-row-${env.name}`}
100
108
  name={env.name}
101
109
  description={env.description}
102
- badges={env.protected ? [{ label: "protected", color: theme.red }] : []}
110
+ badges={env.protected ? [{ label: "protected", tone: "stop" }] : []}
103
111
  onEdit={() => setModal({ kind: "editEnvironment", env })}
104
112
  onDelete={() => setModal({ kind: "removeEnvironment", env })}
105
113
  />
@@ -111,29 +119,28 @@ export function ManifestScreen({ manifest, reloadManifest }: ManifestScreenProps
111
119
  </div>
112
120
 
113
121
  {/* Modals */}
114
- {modal.kind === "addNamespace" && (
115
- <AddNamespaceModal
116
- onClose={closeModal}
117
- onSubmit={async (data) => {
118
- const res = await apiFetch("/api/namespaces", {
119
- method: "POST",
120
- headers: { "Content-Type": "application/json" },
121
- body: JSON.stringify(data),
122
- });
123
- if (!res.ok) {
124
- const body = await res.json();
125
- setError(body.error ?? "Failed to add namespace");
126
- return false;
127
- }
128
- reloadManifest();
129
- closeModal();
130
- return true;
131
- }}
132
- existingNames={namespaces.map((n) => n.name)}
133
- error={error}
134
- setError={setError}
135
- />
136
- )}
122
+ <AddNamespaceModal
123
+ open={modal.kind === "addNamespace"}
124
+ onClose={closeModal}
125
+ onSubmit={async (data) => {
126
+ const res = await apiFetch("/api/namespaces", {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify(data),
130
+ });
131
+ if (!res.ok) {
132
+ const body = await res.json();
133
+ setError(body.error ?? "Failed to add namespace");
134
+ return false;
135
+ }
136
+ reloadManifest();
137
+ closeModal();
138
+ return true;
139
+ }}
140
+ existingNames={namespaces.map((n) => n.name)}
141
+ error={error}
142
+ setError={setError}
143
+ />
137
144
 
138
145
  {modal.kind === "editNamespace" && (
139
146
  <EditNamespaceModal
@@ -184,29 +191,28 @@ export function ManifestScreen({ manifest, reloadManifest }: ManifestScreenProps
184
191
  />
185
192
  )}
186
193
 
187
- {modal.kind === "addEnvironment" && (
188
- <AddEnvironmentModal
189
- existingNames={environments.map((e) => e.name)}
190
- onClose={closeModal}
191
- onSubmit={async (data) => {
192
- const res = await apiFetch("/api/environments", {
193
- method: "POST",
194
- headers: { "Content-Type": "application/json" },
195
- body: JSON.stringify(data),
196
- });
197
- if (!res.ok) {
198
- const body = await res.json();
199
- setError(body.error ?? "Failed to add environment");
200
- return false;
201
- }
202
- reloadManifest();
203
- closeModal();
204
- return true;
205
- }}
206
- error={error}
207
- setError={setError}
208
- />
209
- )}
194
+ <AddEnvironmentModal
195
+ open={modal.kind === "addEnvironment"}
196
+ existingNames={environments.map((e) => e.name)}
197
+ onClose={closeModal}
198
+ onSubmit={async (data) => {
199
+ const res = await apiFetch("/api/environments", {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify(data),
203
+ });
204
+ if (!res.ok) {
205
+ const body = await res.json();
206
+ setError(body.error ?? "Failed to add environment");
207
+ return false;
208
+ }
209
+ reloadManifest();
210
+ closeModal();
211
+ return true;
212
+ }}
213
+ error={error}
214
+ setError={setError}
215
+ />
210
216
 
211
217
  {modal.kind === "editEnvironment" && (
212
218
  <EditEnvironmentModal
@@ -275,24 +281,8 @@ function Section(props: {
275
281
  }) {
276
282
  return (
277
283
  <div>
278
- <div
279
- style={{
280
- display: "flex",
281
- alignItems: "center",
282
- justifyContent: "space-between",
283
- marginBottom: 14,
284
- }}
285
- >
286
- <h2
287
- style={{
288
- fontFamily: theme.sans,
289
- fontSize: 14,
290
- fontWeight: 600,
291
- color: theme.text,
292
- margin: 0,
293
- letterSpacing: "-0.01em",
294
- }}
295
- >
284
+ <div className="flex items-center justify-between mb-3.5">
285
+ <h2 className="font-sans text-[14px] font-semibold text-bone tracking-[-0.01em] m-0">
296
286
  {props.title}
297
287
  </h2>
298
288
  <Button variant="primary" onClick={props.onAction} data-testid={props.actionTestId}>
@@ -306,80 +296,44 @@ function Section(props: {
306
296
 
307
297
  function EntityList(props: { children: React.ReactNode }) {
308
298
  return (
309
- <div
310
- style={{
311
- border: `1px solid ${theme.border}`,
312
- borderRadius: 8,
313
- background: theme.surface,
314
- overflow: "hidden",
315
- }}
316
- >
317
- {props.children}
318
- </div>
299
+ <div className="border border-edge rounded-lg bg-ink-850 overflow-hidden">{props.children}</div>
319
300
  );
320
301
  }
321
302
 
303
+ type BadgeTone = "purple" | "stop";
304
+
305
+ const BADGE_TONE_CLASSES: Record<BadgeTone, string> = {
306
+ purple: "text-purple-400 bg-purple-400/10 border-purple-400/20",
307
+ stop: "text-stop-500 bg-stop-500/10 border-stop-500/20",
308
+ };
309
+
322
310
  function EntityRow(props: {
323
311
  testId: string;
324
312
  name: string;
325
- description: string;
326
- badges: { label: string; color: string }[];
313
+ description: string | undefined;
314
+ badges: { label: string; tone: BadgeTone }[];
327
315
  onEdit: () => void;
328
316
  onDelete: () => void;
329
317
  }) {
330
318
  return (
331
319
  <div
332
320
  data-testid={props.testId}
333
- style={{
334
- display: "flex",
335
- alignItems: "center",
336
- padding: "12px 16px",
337
- borderBottom: `1px solid ${theme.border}`,
338
- gap: 12,
339
- }}
321
+ className="flex items-center px-4 py-3 border-b border-edge last:border-0 gap-3"
340
322
  >
341
- <div style={{ flex: 1, minWidth: 0 }}>
342
- <div
343
- style={{
344
- fontFamily: theme.mono,
345
- fontSize: 13,
346
- fontWeight: 600,
347
- color: theme.text,
348
- display: "flex",
349
- alignItems: "center",
350
- gap: 8,
351
- }}
352
- >
323
+ <div className="flex-1 min-w-0">
324
+ <div className="font-mono text-[13px] font-semibold text-bone flex items-center gap-2">
353
325
  {props.name}
354
326
  {props.badges.map((b) => (
355
327
  <span
356
328
  key={b.label}
357
- style={{
358
- fontFamily: theme.sans,
359
- fontSize: 10,
360
- fontWeight: 500,
361
- color: b.color,
362
- background: `${b.color}14`,
363
- border: `1px solid ${b.color}33`,
364
- borderRadius: 10,
365
- padding: "1px 8px",
366
- }}
329
+ className={`font-sans text-[10px] font-medium border rounded-pill px-2 py-px ${BADGE_TONE_CLASSES[b.tone]}`}
367
330
  >
368
331
  {b.label}
369
332
  </span>
370
333
  ))}
371
334
  </div>
372
335
  {props.description && (
373
- <div
374
- style={{
375
- fontFamily: theme.sans,
376
- fontSize: 12,
377
- color: theme.textMuted,
378
- marginTop: 2,
379
- }}
380
- >
381
- {props.description}
382
- </div>
336
+ <div className="font-sans text-[12px] text-ash mt-0.5">{props.description}</div>
383
337
  )}
384
338
  </div>
385
339
  <Button onClick={props.onEdit} data-testid={`${props.testId}-edit`}>
@@ -392,19 +346,9 @@ function EntityRow(props: {
392
346
  );
393
347
  }
394
348
 
395
- function EmptyState(props: { message: string }) {
349
+ function EmptyMessage(props: { message: string }) {
396
350
  return (
397
- <div
398
- style={{
399
- padding: 24,
400
- border: `1px dashed ${theme.border}`,
401
- borderRadius: 8,
402
- textAlign: "center",
403
- fontFamily: theme.sans,
404
- fontSize: 12,
405
- color: theme.textMuted,
406
- }}
407
- >
351
+ <div className="p-6 border border-dashed border-edge rounded-lg text-center font-sans text-[12px] text-ash">
408
352
  {props.message}
409
353
  </div>
410
354
  );
@@ -412,111 +356,13 @@ function EmptyState(props: { message: string }) {
412
356
 
413
357
  // ── Modal primitives ─────────────────────────────────────────────────────
414
358
 
415
- function ModalShell(props: { title: string; onClose: () => void; children: React.ReactNode }) {
416
- // Stop propagation on inner click so clicking the dialog body doesn't dismiss
417
- return (
418
- <div
419
- data-testid="manifest-modal"
420
- onClick={props.onClose}
421
- style={{
422
- position: "fixed",
423
- inset: 0,
424
- background: "rgba(0,0,0,0.55)",
425
- display: "flex",
426
- alignItems: "center",
427
- justifyContent: "center",
428
- zIndex: 100,
429
- }}
430
- >
431
- <div
432
- onClick={(e) => e.stopPropagation()}
433
- style={{
434
- background: theme.surface,
435
- border: `1px solid ${theme.border}`,
436
- borderRadius: 10,
437
- padding: 24,
438
- width: 480,
439
- maxWidth: "90vw",
440
- }}
441
- >
442
- <h3
443
- style={{
444
- fontFamily: theme.sans,
445
- fontSize: 16,
446
- fontWeight: 600,
447
- color: theme.text,
448
- margin: "0 0 16px",
449
- }}
450
- >
451
- {props.title}
452
- </h3>
453
- {props.children}
454
- </div>
455
- </div>
456
- );
457
- }
458
-
459
- function FormField(props: { label: string; children: React.ReactNode; hint?: string }) {
460
- return (
461
- <div style={{ marginBottom: 14 }}>
462
- <label
463
- style={{
464
- display: "block",
465
- fontFamily: theme.sans,
466
- fontSize: 11,
467
- fontWeight: 600,
468
- color: theme.textMuted,
469
- marginBottom: 4,
470
- textTransform: "uppercase",
471
- letterSpacing: "0.05em",
472
- }}
473
- >
474
- {props.label}
475
- </label>
476
- {props.children}
477
- {props.hint && (
478
- <div
479
- style={{
480
- fontFamily: theme.sans,
481
- fontSize: 11,
482
- color: theme.textMuted,
483
- marginTop: 4,
484
- }}
485
- >
486
- {props.hint}
487
- </div>
488
- )}
489
- </div>
490
- );
491
- }
492
-
493
- function TextInput(props: {
494
- value: string;
495
- onChange: (v: string) => void;
496
- placeholder?: string;
497
- testId?: string;
498
- autoFocus?: boolean;
499
- }) {
359
+ function ModalHeading(props: { children: React.ReactNode }) {
360
+ // Tests rely on an actual heading element (`getByRole("heading")`), so we
361
+ // emit an h3 inside Dialog.Title's wrapper.
500
362
  return (
501
- <input
502
- type="text"
503
- value={props.value}
504
- onChange={(e) => props.onChange(e.target.value)}
505
- placeholder={props.placeholder}
506
- data-testid={props.testId}
507
- autoFocus={props.autoFocus}
508
- style={{
509
- width: "100%",
510
- padding: "8px 12px",
511
- background: theme.bg,
512
- border: `1px solid ${theme.border}`,
513
- borderRadius: 6,
514
- color: theme.text,
515
- fontFamily: theme.mono,
516
- fontSize: 13,
517
- boxSizing: "border-box",
518
- }}
519
- />
363
+ <Dialog.Title>
364
+ <h3 className="font-sans text-[16px] font-semibold text-bone m-0">{props.children}</h3>
365
+ </Dialog.Title>
520
366
  );
521
367
  }
522
368
 
@@ -524,37 +370,13 @@ function ErrorBanner(props: { message: string }) {
524
370
  return (
525
371
  <div
526
372
  data-testid="manifest-modal-error"
527
- style={{
528
- padding: "8px 12px",
529
- background: `${theme.red}14`,
530
- border: `1px solid ${theme.red}33`,
531
- borderRadius: 6,
532
- color: theme.red,
533
- fontFamily: theme.sans,
534
- fontSize: 12,
535
- marginBottom: 12,
536
- }}
373
+ className="px-3 py-2 bg-stop-500/10 border border-stop-500/20 rounded-md text-stop-500 font-sans text-[12px] mb-3"
537
374
  >
538
375
  {props.message}
539
376
  </div>
540
377
  );
541
378
  }
542
379
 
543
- function ModalActions(props: { children: React.ReactNode }) {
544
- return (
545
- <div
546
- style={{
547
- display: "flex",
548
- justifyContent: "flex-end",
549
- gap: 8,
550
- marginTop: 8,
551
- }}
552
- >
553
- {props.children}
554
- </div>
555
- );
556
- }
557
-
558
380
  // Validate identifiers locally for instant feedback. Mirrors the regex used
559
381
  // server-side in StructureManager.assertValidIdentifier.
560
382
  function isValidIdentifier(name: string): boolean {
@@ -564,6 +386,7 @@ function isValidIdentifier(name: string): boolean {
564
386
  // ── Add Namespace ────────────────────────────────────────────────────────
565
387
 
566
388
  function AddNamespaceModal(props: {
389
+ open: boolean;
567
390
  existingNames: string[];
568
391
  onClose: () => void;
569
392
  onSubmit: (data: { name: string; description?: string; schema?: string }) => Promise<boolean>;
@@ -575,6 +398,16 @@ function AddNamespaceModal(props: {
575
398
  const [schema, setSchema] = useState("");
576
399
  const [busy, setBusy] = useState(false);
577
400
 
401
+ // Reset state when the dialog reopens fresh.
402
+ React.useEffect(() => {
403
+ if (props.open) {
404
+ setName("");
405
+ setDescription("");
406
+ setSchema("");
407
+ setBusy(false);
408
+ }
409
+ }, [props.open]);
410
+
578
411
  const trimmed = name.trim();
579
412
  const collides = props.existingNames.includes(trimmed);
580
413
  const valid = trimmed.length > 0 && isValidIdentifier(trimmed) && !collides;
@@ -587,37 +420,42 @@ function AddNamespaceModal(props: {
587
420
  : null;
588
421
 
589
422
  return (
590
- <ModalShell title="Add namespace" onClose={props.onClose}>
591
- {props.error && <ErrorBanner message={props.error} />}
592
- <FormField label="Name" hint={localError ?? undefined}>
593
- <TextInput
594
- value={name}
595
- onChange={(v) => {
596
- setName(v);
597
- props.setError(null);
598
- }}
599
- placeholder="payments"
600
- testId="namespace-name-input"
601
- autoFocus
602
- />
603
- </FormField>
604
- <FormField label="Description">
605
- <TextInput
606
- value={description}
607
- onChange={setDescription}
608
- placeholder="Payment processing secrets"
609
- testId="namespace-description-input"
610
- />
611
- </FormField>
612
- <FormField label="Schema (optional)" hint="Path to a YAML schema file in the repo.">
613
- <TextInput
614
- value={schema}
615
- onChange={setSchema}
616
- placeholder="schemas/payments.yaml"
617
- testId="namespace-schema-input"
618
- />
619
- </FormField>
620
- <ModalActions>
423
+ <Dialog open={props.open} onClose={props.onClose}>
424
+ <ModalHeading>Add namespace</ModalHeading>
425
+ <Dialog.Body>
426
+ {props.error && <ErrorBanner message={props.error} />}
427
+ <div className="flex flex-col gap-3.5">
428
+ <Field label="Name" hint={localError ?? undefined}>
429
+ <Input
430
+ value={name}
431
+ onChange={(e) => {
432
+ setName(e.target.value);
433
+ props.setError(null);
434
+ }}
435
+ placeholder="payments"
436
+ data-testid="namespace-name-input"
437
+ autoFocus
438
+ />
439
+ </Field>
440
+ <Field label="Description">
441
+ <Input
442
+ value={description}
443
+ onChange={(e) => setDescription(e.target.value)}
444
+ placeholder="Payment processing secrets"
445
+ data-testid="namespace-description-input"
446
+ />
447
+ </Field>
448
+ <Field label="Schema (optional)" hint="Path to a YAML schema file in the repo.">
449
+ <Input
450
+ value={schema}
451
+ onChange={(e) => setSchema(e.target.value)}
452
+ placeholder="schemas/payments.yaml"
453
+ data-testid="namespace-schema-input"
454
+ />
455
+ </Field>
456
+ </div>
457
+ </Dialog.Body>
458
+ <Dialog.Footer>
621
459
  <Button onClick={props.onClose} data-testid="namespace-add-cancel">
622
460
  Cancel
623
461
  </Button>
@@ -637,8 +475,8 @@ function AddNamespaceModal(props: {
637
475
  >
638
476
  {busy ? "Adding..." : "Add namespace"}
639
477
  </Button>
640
- </ModalActions>
641
- </ModalShell>
478
+ </Dialog.Footer>
479
+ </Dialog>
642
480
  );
643
481
  }
644
482
 
@@ -674,30 +512,39 @@ function EditNamespaceModal(props: {
674
512
  isRename || description !== (props.ns.description ?? "") || schema !== (props.ns.schema ?? "");
675
513
 
676
514
  return (
677
- <ModalShell title={`Edit namespace '${props.ns.name}'`} onClose={props.onClose}>
678
- {props.error && <ErrorBanner message={props.error} />}
679
- <FormField label="Name" hint={localError ?? undefined}>
680
- <TextInput
681
- value={rename}
682
- onChange={(v) => {
683
- setRename(v);
684
- props.setError(null);
685
- }}
686
- testId="namespace-rename-input"
687
- autoFocus
688
- />
689
- </FormField>
690
- <FormField label="Description">
691
- <TextInput
692
- value={description}
693
- onChange={setDescription}
694
- testId="namespace-description-input"
695
- />
696
- </FormField>
697
- <FormField label="Schema (optional)" hint="Empty to clear.">
698
- <TextInput value={schema} onChange={setSchema} testId="namespace-schema-input" />
699
- </FormField>
700
- <ModalActions>
515
+ <Dialog open onClose={props.onClose}>
516
+ <ModalHeading>{`Edit namespace '${props.ns.name}'`}</ModalHeading>
517
+ <Dialog.Body>
518
+ {props.error && <ErrorBanner message={props.error} />}
519
+ <div className="flex flex-col gap-3.5">
520
+ <Field label="Name" hint={localError ?? undefined}>
521
+ <Input
522
+ value={rename}
523
+ onChange={(e) => {
524
+ setRename(e.target.value);
525
+ props.setError(null);
526
+ }}
527
+ data-testid="namespace-rename-input"
528
+ autoFocus
529
+ />
530
+ </Field>
531
+ <Field label="Description">
532
+ <Input
533
+ value={description}
534
+ onChange={(e) => setDescription(e.target.value)}
535
+ data-testid="namespace-description-input"
536
+ />
537
+ </Field>
538
+ <Field label="Schema (optional)" hint="Empty to clear.">
539
+ <Input
540
+ value={schema}
541
+ onChange={(e) => setSchema(e.target.value)}
542
+ data-testid="namespace-schema-input"
543
+ />
544
+ </Field>
545
+ </div>
546
+ </Dialog.Body>
547
+ <Dialog.Footer>
701
548
  <Button onClick={props.onClose} data-testid="namespace-edit-cancel">
702
549
  Cancel
703
550
  </Button>
@@ -717,14 +564,15 @@ function EditNamespaceModal(props: {
717
564
  >
718
565
  {busy ? "Saving..." : "Save changes"}
719
566
  </Button>
720
- </ModalActions>
721
- </ModalShell>
567
+ </Dialog.Footer>
568
+ </Dialog>
722
569
  );
723
570
  }
724
571
 
725
572
  // ── Add Environment ──────────────────────────────────────────────────────
726
573
 
727
574
  function AddEnvironmentModal(props: {
575
+ open: boolean;
728
576
  existingNames: string[];
729
577
  onClose: () => void;
730
578
  onSubmit: (data: { name: string; description?: string; protected?: boolean }) => Promise<boolean>;
@@ -736,6 +584,15 @@ function AddEnvironmentModal(props: {
736
584
  const [isProtected, setIsProtected] = useState(false);
737
585
  const [busy, setBusy] = useState(false);
738
586
 
587
+ React.useEffect(() => {
588
+ if (props.open) {
589
+ setName("");
590
+ setDescription("");
591
+ setIsProtected(false);
592
+ setBusy(false);
593
+ }
594
+ }, [props.open]);
595
+
739
596
  const trimmed = name.trim();
740
597
  const collides = props.existingNames.includes(trimmed);
741
598
  const valid = trimmed.length > 0 && isValidIdentifier(trimmed) && !collides;
@@ -748,50 +605,43 @@ function AddEnvironmentModal(props: {
748
605
  : null;
749
606
 
750
607
  return (
751
- <ModalShell title="Add environment" onClose={props.onClose}>
752
- {props.error && <ErrorBanner message={props.error} />}
753
- <FormField label="Name" hint={localError ?? undefined}>
754
- <TextInput
755
- value={name}
756
- onChange={(v) => {
757
- setName(v);
758
- props.setError(null);
759
- }}
760
- placeholder="staging"
761
- testId="environment-name-input"
762
- autoFocus
763
- />
764
- </FormField>
765
- <FormField label="Description">
766
- <TextInput
767
- value={description}
768
- onChange={setDescription}
769
- placeholder="Pre-production"
770
- testId="environment-description-input"
771
- />
772
- </FormField>
773
- <div style={{ marginBottom: 14 }}>
774
- <label
775
- style={{
776
- display: "flex",
777
- alignItems: "center",
778
- gap: 8,
779
- fontFamily: theme.sans,
780
- fontSize: 12,
781
- color: theme.text,
782
- cursor: "pointer",
783
- }}
784
- >
785
- <input
786
- type="checkbox"
787
- checked={isProtected}
788
- onChange={(e) => setIsProtected(e.target.checked)}
789
- data-testid="environment-protected-checkbox"
790
- />
791
- Mark as protected
792
- </label>
793
- </div>
794
- <ModalActions>
608
+ <Dialog open={props.open} onClose={props.onClose}>
609
+ <ModalHeading>Add environment</ModalHeading>
610
+ <Dialog.Body>
611
+ {props.error && <ErrorBanner message={props.error} />}
612
+ <div className="flex flex-col gap-3.5">
613
+ <Field label="Name" hint={localError ?? undefined}>
614
+ <Input
615
+ value={name}
616
+ onChange={(e) => {
617
+ setName(e.target.value);
618
+ props.setError(null);
619
+ }}
620
+ placeholder="staging"
621
+ data-testid="environment-name-input"
622
+ autoFocus
623
+ />
624
+ </Field>
625
+ <Field label="Description">
626
+ <Input
627
+ value={description}
628
+ onChange={(e) => setDescription(e.target.value)}
629
+ placeholder="Pre-production"
630
+ data-testid="environment-description-input"
631
+ />
632
+ </Field>
633
+ <label className="flex items-center gap-2 font-sans text-[12px] text-bone cursor-pointer">
634
+ <input
635
+ type="checkbox"
636
+ checked={isProtected}
637
+ onChange={(e) => setIsProtected(e.target.checked)}
638
+ data-testid="environment-protected-checkbox"
639
+ />
640
+ Mark as protected
641
+ </label>
642
+ </div>
643
+ </Dialog.Body>
644
+ <Dialog.Footer>
795
645
  <Button onClick={props.onClose} data-testid="environment-add-cancel">
796
646
  Cancel
797
647
  </Button>
@@ -811,8 +661,8 @@ function AddEnvironmentModal(props: {
811
661
  >
812
662
  {busy ? "Adding..." : "Add environment"}
813
663
  </Button>
814
- </ModalActions>
815
- </ModalShell>
664
+ </Dialog.Footer>
665
+ </Dialog>
816
666
  );
817
667
  }
818
668
 
@@ -851,48 +701,41 @@ function EditEnvironmentModal(props: {
851
701
  const dirty = isRename || description !== (props.env.description ?? "") || protectedChanged;
852
702
 
853
703
  return (
854
- <ModalShell title={`Edit environment '${props.env.name}'`} onClose={props.onClose}>
855
- {props.error && <ErrorBanner message={props.error} />}
856
- <FormField label="Name" hint={localError ?? undefined}>
857
- <TextInput
858
- value={rename}
859
- onChange={(v) => {
860
- setRename(v);
861
- props.setError(null);
862
- }}
863
- testId="environment-rename-input"
864
- autoFocus
865
- />
866
- </FormField>
867
- <FormField label="Description">
868
- <TextInput
869
- value={description}
870
- onChange={setDescription}
871
- testId="environment-description-input"
872
- />
873
- </FormField>
874
- <div style={{ marginBottom: 14 }}>
875
- <label
876
- style={{
877
- display: "flex",
878
- alignItems: "center",
879
- gap: 8,
880
- fontFamily: theme.sans,
881
- fontSize: 12,
882
- color: theme.text,
883
- cursor: "pointer",
884
- }}
885
- >
886
- <input
887
- type="checkbox"
888
- checked={isProtected}
889
- onChange={(e) => setIsProtected(e.target.checked)}
890
- data-testid="environment-protected-checkbox"
891
- />
892
- Protected (write operations require confirmation)
893
- </label>
894
- </div>
895
- <ModalActions>
704
+ <Dialog open onClose={props.onClose}>
705
+ <ModalHeading>{`Edit environment '${props.env.name}'`}</ModalHeading>
706
+ <Dialog.Body>
707
+ {props.error && <ErrorBanner message={props.error} />}
708
+ <div className="flex flex-col gap-3.5">
709
+ <Field label="Name" hint={localError ?? undefined}>
710
+ <Input
711
+ value={rename}
712
+ onChange={(e) => {
713
+ setRename(e.target.value);
714
+ props.setError(null);
715
+ }}
716
+ data-testid="environment-rename-input"
717
+ autoFocus
718
+ />
719
+ </Field>
720
+ <Field label="Description">
721
+ <Input
722
+ value={description}
723
+ onChange={(e) => setDescription(e.target.value)}
724
+ data-testid="environment-description-input"
725
+ />
726
+ </Field>
727
+ <label className="flex items-center gap-2 font-sans text-[12px] text-bone cursor-pointer">
728
+ <input
729
+ type="checkbox"
730
+ checked={isProtected}
731
+ onChange={(e) => setIsProtected(e.target.checked)}
732
+ data-testid="environment-protected-checkbox"
733
+ />
734
+ Protected (write operations require confirmation)
735
+ </label>
736
+ </div>
737
+ </Dialog.Body>
738
+ <Dialog.Footer>
896
739
  <Button onClick={props.onClose} data-testid="environment-edit-cancel">
897
740
  Cancel
898
741
  </Button>
@@ -912,8 +755,8 @@ function EditEnvironmentModal(props: {
912
755
  >
913
756
  {busy ? "Saving..." : "Save changes"}
914
757
  </Button>
915
- </ModalActions>
916
- </ModalShell>
758
+ </Dialog.Footer>
759
+ </Dialog>
917
760
  );
918
761
  }
919
762
 
@@ -933,29 +776,24 @@ function ConfirmRemoveModal(props: {
933
776
  const matches = typedName === props.subjectName;
934
777
 
935
778
  return (
936
- <ModalShell title={props.title} onClose={props.onClose}>
937
- {props.error && <ErrorBanner message={props.error} />}
938
- <p
939
- style={{
940
- fontFamily: theme.sans,
941
- fontSize: 12,
942
- color: theme.text,
943
- margin: "0 0 12px",
944
- lineHeight: 1.5,
945
- }}
946
- >
947
- {props.impactDescription}
948
- </p>
949
- <FormField label={`Type the ${props.subjectKind} name to confirm`}>
950
- <TextInput
951
- value={typedName}
952
- onChange={setTypedName}
953
- placeholder={props.subjectName}
954
- testId={`${props.subjectKind}-remove-confirm-input`}
955
- autoFocus
956
- />
957
- </FormField>
958
- <ModalActions>
779
+ <Dialog open onClose={props.onClose}>
780
+ <ModalHeading>{props.title}</ModalHeading>
781
+ <Dialog.Body>
782
+ {props.error && <ErrorBanner message={props.error} />}
783
+ <p className="font-sans text-[12px] text-bone m-0 mb-3 leading-relaxed">
784
+ {props.impactDescription}
785
+ </p>
786
+ <Field label={`Type the ${props.subjectKind} name to confirm`}>
787
+ <Input
788
+ value={typedName}
789
+ onChange={(e) => setTypedName(e.target.value)}
790
+ placeholder={props.subjectName}
791
+ data-testid={`${props.subjectKind}-remove-confirm-input`}
792
+ autoFocus
793
+ />
794
+ </Field>
795
+ </Dialog.Body>
796
+ <Dialog.Footer>
959
797
  <Button onClick={props.onClose} data-testid={`${props.subjectKind}-remove-cancel`}>
960
798
  Cancel
961
799
  </Button>
@@ -971,7 +809,7 @@ function ConfirmRemoveModal(props: {
971
809
  >
972
810
  {busy ? "Deleting..." : `Delete ${props.subjectKind}`}
973
811
  </Button>
974
- </ModalActions>
975
- </ModalShell>
812
+ </Dialog.Footer>
813
+ </Dialog>
976
814
  );
977
815
  }