@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,5 +1,4 @@
1
1
  import React from "react";
2
- import { theme } from "../theme";
3
2
 
4
3
  interface TopBarProps {
5
4
  title: string;
@@ -7,44 +6,20 @@ interface TopBarProps {
7
6
  actions?: React.ReactNode;
8
7
  }
9
8
 
9
+ /**
10
+ * @deprecated Prefer the `<Toolbar>` primitive from `../primitives`. This
11
+ * thin wrapper exists for back-compat — every screen has migrated to
12
+ * `<Toolbar>`, but `TopBar` is still part of the public `@clef-sh/ui`
13
+ * client-lib export, so external consumers of the package may rely on it.
14
+ */
10
15
  export function TopBar({ title, subtitle, actions }: TopBarProps) {
11
16
  return (
12
- <div
13
- style={{
14
- height: 54,
15
- borderBottom: `1px solid ${theme.border}`,
16
- display: "flex",
17
- alignItems: "center",
18
- padding: "0 24px",
19
- gap: 16,
20
- flexShrink: 0,
21
- }}
22
- >
23
- <div style={{ flex: 1 }}>
24
- <div
25
- style={{
26
- fontFamily: theme.sans,
27
- fontWeight: 600,
28
- fontSize: 14,
29
- color: theme.text,
30
- }}
31
- >
32
- {title}
33
- </div>
34
- {subtitle && (
35
- <div
36
- style={{
37
- fontFamily: theme.mono,
38
- fontSize: 10,
39
- color: theme.textMuted,
40
- marginTop: 1,
41
- }}
42
- >
43
- {subtitle}
44
- </div>
45
- )}
17
+ <div className="flex h-[54px] shrink-0 items-center gap-4 border-b border-edge px-6">
18
+ <div className="flex-1">
19
+ <div className="font-sans text-[14px] font-semibold text-bone">{title}</div>
20
+ {subtitle && <div className="mt-px font-mono text-[10px] text-ash">{subtitle}</div>}
46
21
  </div>
47
- <div style={{ display: "flex", gap: 8 }}>{actions}</div>
22
+ <div className="flex gap-2">{actions}</div>
48
23
  </div>
49
24
  );
50
25
  }
@@ -10,38 +10,9 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link
13
- href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600;700&display=swap"
13
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600;700&display=swap"
14
14
  rel="stylesheet"
15
15
  />
16
- <style>
17
- * {
18
- box-sizing: border-box;
19
- margin: 0;
20
- padding: 0;
21
- }
22
- body {
23
- background: #0a0b0d;
24
- color: #e8eaf0;
25
- font-family: "DM Sans", system-ui, sans-serif;
26
- }
27
- ::-webkit-scrollbar {
28
- width: 6px;
29
- height: 6px;
30
- }
31
- ::-webkit-scrollbar-track {
32
- background: transparent;
33
- }
34
- ::-webkit-scrollbar-thumb {
35
- background: #1e2330;
36
- border-radius: 3px;
37
- }
38
- select option {
39
- background: #111318;
40
- }
41
- input::placeholder {
42
- color: #3d4455;
43
- }
44
- </style>
45
16
  </head>
46
17
  <body>
47
18
  <div id="root"></div>
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import ReactDOM from "react-dom/client";
3
+ import "./styles.css";
3
4
  import App from "./App";
4
5
  import { initToken } from "./api";
5
6
 
@@ -0,0 +1,47 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { Badge } from "./Badge";
5
+
6
+ describe("Badge", () => {
7
+ it("renders children with default outline + default tone classes", () => {
8
+ render(<Badge data-testid="b">PROD</Badge>);
9
+ const el = screen.getByTestId("b");
10
+ expect(el).toHaveTextContent("PROD");
11
+ expect(el.className).toContain("border-edge");
12
+ expect(el.className).toContain("text-ash-dim");
13
+ });
14
+
15
+ it("applies go solid variant classes", () => {
16
+ render(
17
+ <Badge tone="go" variant="solid" data-testid="b">
18
+ OK
19
+ </Badge>,
20
+ );
21
+ const el = screen.getByTestId("b");
22
+ expect(el.className).toContain("bg-go-500/15");
23
+ expect(el.className).toContain("text-go-500");
24
+ });
25
+
26
+ it("applies stop outline classes", () => {
27
+ render(
28
+ <Badge tone="stop" data-testid="b">
29
+ FAIL
30
+ </Badge>,
31
+ );
32
+ const el = screen.getByTestId("b");
33
+ expect(el.className).toContain("border-stop-500/40");
34
+ expect(el.className).toContain("text-stop-500");
35
+ });
36
+
37
+ it("applies blue tone using blue-400 token", () => {
38
+ render(
39
+ <Badge tone="blue" data-testid="b">
40
+ INFO
41
+ </Badge>,
42
+ );
43
+ const el = screen.getByTestId("b");
44
+ expect(el.className).toContain("border-blue-400/40");
45
+ expect(el.className).toContain("text-blue-400");
46
+ });
47
+ });
@@ -0,0 +1,64 @@
1
+ import React from "react";
2
+
3
+ export type BadgeTone = "default" | "go" | "warn" | "stop" | "gold" | "blue" | "purple";
4
+
5
+ export type BadgeVariant = "solid" | "outline";
6
+
7
+ export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
8
+ tone?: BadgeTone;
9
+ variant?: BadgeVariant;
10
+ className?: string;
11
+ children?: React.ReactNode;
12
+ }
13
+
14
+ function joinClasses(...parts: Array<string | false | null | undefined>): string {
15
+ return parts.filter(Boolean).join(" ");
16
+ }
17
+
18
+ function toneClasses(tone: BadgeTone, variant: BadgeVariant): string {
19
+ // For each tone we return the class string for the chosen variant.
20
+ switch (tone) {
21
+ case "go":
22
+ return variant === "solid"
23
+ ? "bg-go-500/15 text-go-500 border border-transparent"
24
+ : "border border-go-500/40 text-go-500";
25
+ case "warn":
26
+ return variant === "solid"
27
+ ? "bg-warn-500/15 text-warn-500 border border-transparent"
28
+ : "border border-warn-500/40 text-warn-500";
29
+ case "stop":
30
+ return variant === "solid"
31
+ ? "bg-stop-500/15 text-stop-500 border border-transparent"
32
+ : "border border-stop-500/40 text-stop-500";
33
+ case "gold":
34
+ return variant === "solid"
35
+ ? "bg-gold-500/15 text-gold-500 border border-transparent"
36
+ : "border border-gold-500/40 text-gold-500";
37
+ case "blue":
38
+ return variant === "solid"
39
+ ? "bg-blue-400/15 text-blue-400 border border-transparent"
40
+ : "border border-blue-400/40 text-blue-400";
41
+ case "purple":
42
+ return variant === "solid"
43
+ ? "bg-purple-400/15 text-purple-400 border border-transparent"
44
+ : "border border-purple-400/40 text-purple-400";
45
+ case "default":
46
+ default:
47
+ return variant === "solid"
48
+ ? "bg-edge text-ash border border-transparent"
49
+ : "border border-edge text-ash-dim";
50
+ }
51
+ }
52
+
53
+ export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
54
+ { tone = "default", variant = "outline", className, children, ...rest },
55
+ ref,
56
+ ) {
57
+ const base =
58
+ "inline-flex items-center font-mono text-[9px] font-bold uppercase tracking-[0.08em] rounded-sm px-1.5 py-0.5";
59
+ return (
60
+ <span ref={ref} className={joinClasses(base, toneClasses(tone, variant), className)} {...rest}>
61
+ {children}
62
+ </span>
63
+ );
64
+ });
@@ -0,0 +1,50 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { Card } from "./Card";
5
+
6
+ describe("Card", () => {
7
+ it("renders header title and subtitle and body content", () => {
8
+ render(
9
+ <Card data-testid="card">
10
+ <Card.Header title="Identity" subtitle="namespace" />
11
+ <Card.Body>hello body</Card.Body>
12
+ </Card>,
13
+ );
14
+ expect(screen.getByTestId("card")).toBeInTheDocument();
15
+ expect(screen.getByText("Identity")).toBeInTheDocument();
16
+ expect(screen.getByText("namespace")).toBeInTheDocument();
17
+ expect(screen.getByText("hello body")).toBeInTheDocument();
18
+ });
19
+
20
+ it("applies error tone border class", () => {
21
+ render(
22
+ <Card tone="error" data-testid="card">
23
+ <Card.Body>broken</Card.Body>
24
+ </Card>,
25
+ );
26
+ const root = screen.getByTestId("card");
27
+ expect(root.className).toContain("border-stop-500/40");
28
+ expect(root.className).not.toContain("border-edge ");
29
+ });
30
+
31
+ it("applies interactive hover classes when interactive prop set", () => {
32
+ render(
33
+ <Card interactive data-testid="card">
34
+ <Card.Body>hover me</Card.Body>
35
+ </Card>,
36
+ );
37
+ const root = screen.getByTestId("card");
38
+ expect(root.className).toContain("hover:border-edge-strong");
39
+ expect(root.className).toContain("hover:shadow-soft-drop");
40
+ });
41
+
42
+ it("renders header actions slot", () => {
43
+ render(
44
+ <Card>
45
+ <Card.Header title="Files" actions={<button>add</button>} />
46
+ </Card>,
47
+ );
48
+ expect(screen.getByRole("button", { name: "add" })).toBeInTheDocument();
49
+ });
50
+ });
@@ -0,0 +1,85 @@
1
+ import React from "react";
2
+
3
+ type CardTone = "default" | "error";
4
+
5
+ export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
6
+ tone?: CardTone;
7
+ interactive?: boolean;
8
+ className?: string;
9
+ children?: React.ReactNode;
10
+ }
11
+
12
+ function joinClasses(...parts: Array<string | false | null | undefined>): string {
13
+ return parts.filter(Boolean).join(" ");
14
+ }
15
+
16
+ const CardRoot = React.forwardRef<HTMLDivElement, CardProps>(function Card(
17
+ { tone = "default", interactive = false, className, children, ...rest },
18
+ ref,
19
+ ) {
20
+ const base = "bg-ink-850 border rounded-card";
21
+ const borderTone = tone === "error" ? "border-stop-500/40" : "border-edge";
22
+ const interactiveClasses = interactive
23
+ ? "transition-shadow transition-colors hover:border-edge-strong hover:shadow-soft-drop"
24
+ : "";
25
+ return (
26
+ <div
27
+ ref={ref}
28
+ className={joinClasses(base, borderTone, interactiveClasses, className)}
29
+ {...rest}
30
+ >
31
+ {children}
32
+ </div>
33
+ );
34
+ });
35
+
36
+ export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
37
+ title: string;
38
+ subtitle?: string;
39
+ actions?: React.ReactNode;
40
+ className?: string;
41
+ }
42
+
43
+ function CardHeader({ title, subtitle, actions, className, ...rest }: CardHeaderProps) {
44
+ return (
45
+ <div
46
+ className={joinClasses(
47
+ "flex items-center justify-between px-4 py-3 border-b border-edge",
48
+ className,
49
+ )}
50
+ {...rest}
51
+ >
52
+ <div className="min-w-0">
53
+ <div className="font-sans text-[13px] font-bold text-bone">{title}</div>
54
+ {subtitle ? (
55
+ <div className="font-mono text-[10px] text-ash-dim mt-0.5">{subtitle}</div>
56
+ ) : null}
57
+ </div>
58
+ {actions ? <div className="flex gap-2 shrink-0">{actions}</div> : null}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ export interface CardBodyProps extends React.HTMLAttributes<HTMLDivElement> {
64
+ className?: string;
65
+ children?: React.ReactNode;
66
+ }
67
+
68
+ function CardBody({ className, children, ...rest }: CardBodyProps) {
69
+ return (
70
+ <div className={joinClasses("p-4", className)} {...rest}>
71
+ {children}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ type CardCompound = typeof CardRoot & {
77
+ Header: typeof CardHeader;
78
+ Body: typeof CardBody;
79
+ };
80
+
81
+ const Card = CardRoot as CardCompound;
82
+ Card.Header = CardHeader;
83
+ Card.Body = CardBody;
84
+
85
+ export { Card };
@@ -0,0 +1,55 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { Dialog } from "./Dialog";
5
+
6
+ describe("Dialog", () => {
7
+ it("renders nothing when closed", () => {
8
+ render(
9
+ <Dialog open={false} onClose={() => {}}>
10
+ <Dialog.Title>Hidden</Dialog.Title>
11
+ </Dialog>,
12
+ );
13
+ expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
14
+ });
15
+
16
+ it("renders title, body, and footer when open", () => {
17
+ render(
18
+ <Dialog open={true} onClose={() => {}}>
19
+ <Dialog.Title>Confirm</Dialog.Title>
20
+ <Dialog.Body>are you sure?</Dialog.Body>
21
+ <Dialog.Footer>
22
+ <button>ok</button>
23
+ </Dialog.Footer>
24
+ </Dialog>,
25
+ );
26
+ expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
27
+ expect(screen.getByText("Confirm")).toBeInTheDocument();
28
+ expect(screen.getByText("are you sure?")).toBeInTheDocument();
29
+ expect(screen.getByRole("button", { name: "ok" })).toBeInTheDocument();
30
+ });
31
+
32
+ it("calls onClose when scrim is clicked but not when panel is clicked", () => {
33
+ const onClose = jest.fn();
34
+ render(
35
+ <Dialog open={true} onClose={onClose}>
36
+ <Dialog.Body>panel</Dialog.Body>
37
+ </Dialog>,
38
+ );
39
+ fireEvent.click(screen.getByRole("dialog"));
40
+ expect(onClose).not.toHaveBeenCalled();
41
+ fireEvent.click(screen.getByTestId("dialog-scrim"));
42
+ expect(onClose).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it("calls onClose when Escape is pressed", () => {
46
+ const onClose = jest.fn();
47
+ render(
48
+ <Dialog open={true} onClose={onClose}>
49
+ <Dialog.Body>panel</Dialog.Body>
50
+ </Dialog>,
51
+ );
52
+ fireEvent.keyDown(window, { key: "Escape" });
53
+ expect(onClose).toHaveBeenCalledTimes(1);
54
+ });
55
+ });
@@ -0,0 +1,96 @@
1
+ import React, { useEffect } from "react";
2
+
3
+ function joinClasses(...parts: Array<string | false | null | undefined>): string {
4
+ return parts.filter(Boolean).join(" ");
5
+ }
6
+
7
+ export interface DialogProps {
8
+ open: boolean;
9
+ onClose: () => void;
10
+ children?: React.ReactNode;
11
+ }
12
+
13
+ function DialogRoot({ open, onClose, children }: DialogProps) {
14
+ useEffect(() => {
15
+ if (!open) return;
16
+ function handleKey(e: KeyboardEvent) {
17
+ if (e.key === "Escape") onClose();
18
+ }
19
+ window.addEventListener("keydown", handleKey);
20
+ return () => window.removeEventListener("keydown", handleKey);
21
+ }, [open, onClose]);
22
+
23
+ if (!open) return null;
24
+
25
+ return (
26
+ <div
27
+ data-testid="dialog-scrim"
28
+ className="fixed inset-0 z-50 bg-[rgba(4,5,8,0.72)] flex items-center justify-center"
29
+ onClick={onClose}
30
+ >
31
+ <div
32
+ role="dialog"
33
+ aria-modal="true"
34
+ className="bg-ink-850 border border-edge rounded-card shadow-plate w-full max-w-md p-5"
35
+ onClick={(e) => e.stopPropagation()}
36
+ >
37
+ {children}
38
+ </div>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ export interface DialogTitleProps extends React.HTMLAttributes<HTMLDivElement> {
44
+ className?: string;
45
+ children?: React.ReactNode;
46
+ }
47
+
48
+ function DialogTitle({ className, children, ...rest }: DialogTitleProps) {
49
+ return (
50
+ <div
51
+ className={joinClasses("font-sans text-[15px] font-semibold text-bone mb-3", className)}
52
+ {...rest}
53
+ >
54
+ {children}
55
+ </div>
56
+ );
57
+ }
58
+
59
+ export interface DialogBodyProps extends React.HTMLAttributes<HTMLDivElement> {
60
+ className?: string;
61
+ children?: React.ReactNode;
62
+ }
63
+
64
+ function DialogBody({ className, children, ...rest }: DialogBodyProps) {
65
+ return (
66
+ <div className={joinClasses("font-sans text-[13px] text-bone", className)} {...rest}>
67
+ {children}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {
73
+ className?: string;
74
+ children?: React.ReactNode;
75
+ }
76
+
77
+ function DialogFooter({ className, children, ...rest }: DialogFooterProps) {
78
+ return (
79
+ <div className={joinClasses("flex gap-2 justify-end mt-4", className)} {...rest}>
80
+ {children}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ type DialogCompound = typeof DialogRoot & {
86
+ Title: typeof DialogTitle;
87
+ Body: typeof DialogBody;
88
+ Footer: typeof DialogFooter;
89
+ };
90
+
91
+ const Dialog = DialogRoot as DialogCompound;
92
+ Dialog.Title = DialogTitle;
93
+ Dialog.Body = DialogBody;
94
+ Dialog.Footer = DialogFooter;
95
+
96
+ export { Dialog };
@@ -0,0 +1,25 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { EmptyState } from "./EmptyState";
5
+
6
+ describe("EmptyState", () => {
7
+ it("renders title only when no body or action", () => {
8
+ render(<EmptyState title="No keys declared yet" data-testid="empty" />);
9
+ expect(screen.getByText("No keys declared yet")).toBeInTheDocument();
10
+ expect(screen.getByTestId("empty").className).toContain("border-dashed");
11
+ });
12
+
13
+ it("renders body and action when provided", () => {
14
+ render(
15
+ <EmptyState title="Loading" body="Loading manifest..." action={<button>Retry</button>} />,
16
+ );
17
+ expect(screen.getByText("Loading manifest...")).toBeInTheDocument();
18
+ expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
19
+ });
20
+
21
+ it("renders icon slot", () => {
22
+ render(<EmptyState icon={<span data-testid="ic">!</span>} title="Empty" />);
23
+ expect(screen.getByTestId("ic")).toBeInTheDocument();
24
+ });
25
+ });
@@ -0,0 +1,38 @@
1
+ import React from "react";
2
+
3
+ function joinClasses(...parts: Array<string | false | null | undefined>): string {
4
+ return parts.filter(Boolean).join(" ");
5
+ }
6
+
7
+ export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
8
+ icon?: React.ReactNode;
9
+ title: string;
10
+ body?: string;
11
+ action?: React.ReactNode;
12
+ className?: string;
13
+ }
14
+
15
+ export const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(
16
+ { icon, title, body, action, className, ...rest },
17
+ ref,
18
+ ) {
19
+ return (
20
+ <div
21
+ ref={ref}
22
+ className={joinClasses(
23
+ "flex flex-col items-center justify-center border border-dashed border-edge rounded-md p-6 text-center",
24
+ className,
25
+ )}
26
+ {...rest}
27
+ >
28
+ {icon ? (
29
+ <div className="text-ash-dim mb-3 text-[32px] leading-none" aria-hidden>
30
+ {icon}
31
+ </div>
32
+ ) : null}
33
+ <div className="font-sans text-[13px] font-semibold text-bone">{title}</div>
34
+ {body ? <div className="font-sans text-[12px] text-ash-dim mt-1">{body}</div> : null}
35
+ {action ? <div className="mt-3">{action}</div> : null}
36
+ </div>
37
+ );
38
+ });
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { Field, Input, Textarea } from "./Field";
5
+
6
+ describe("Field", () => {
7
+ it("renders label and child input", () => {
8
+ render(
9
+ <Field label="Name">
10
+ <Input placeholder="enter name" />
11
+ </Field>,
12
+ );
13
+ expect(screen.getByText("Name")).toBeInTheDocument();
14
+ expect(screen.getByPlaceholderText("enter name")).toBeInTheDocument();
15
+ });
16
+
17
+ it("shows hint when provided and no error", () => {
18
+ render(
19
+ <Field label="Pattern" hint="regex string">
20
+ <Input />
21
+ </Field>,
22
+ );
23
+ expect(screen.getByText("regex string")).toBeInTheDocument();
24
+ });
25
+
26
+ it("shows error and hides hint when error present", () => {
27
+ render(
28
+ <Field label="URL" hint="https://...." error="invalid URL">
29
+ <Input />
30
+ </Field>,
31
+ );
32
+ expect(screen.getByRole("alert")).toHaveTextContent("invalid URL");
33
+ expect(screen.queryByText("https://....")).not.toBeInTheDocument();
34
+ });
35
+
36
+ it("renders required asterisk when required", () => {
37
+ render(
38
+ <Field label="Required field" required>
39
+ <Textarea />
40
+ </Field>,
41
+ );
42
+ expect(screen.getByText(/Required field/)).toBeInTheDocument();
43
+ const star = screen.getByText("*");
44
+ expect(star.className).toContain("text-stop-500");
45
+ });
46
+ });