diff --git a/canvas/src/components/ui/__tests__/KeyValueField.test.tsx b/canvas/src/components/ui/__tests__/KeyValueField.test.tsx
new file mode 100644
index 00000000..1603faa6
--- /dev/null
+++ b/canvas/src/components/ui/__tests__/KeyValueField.test.tsx
@@ -0,0 +1,142 @@
+// @vitest-environment jsdom
+/**
+ * Tests for KeyValueField component.
+ *
+ * Covers: initial password type, onChange callback (including whitespace trim
+ * on type), aria-label forwarding, disabled state, and auto-hide timer setup.
+ */
+import React from "react";
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
+import { KeyValueField } from "../KeyValueField";
+
+describe("KeyValueField — rendering", () => {
+ afterEach(cleanup);
+
+ it("renders input with type=password by default (secret hidden)", () => {
+ render();
+ const input = screen.getByLabelText("Secret value");
+ expect(input.getAttribute("type")).toBe("password");
+ });
+
+ it("passes custom aria-label to the input element", () => {
+ render();
+ expect(screen.getByLabelText("API secret key")).toBeTruthy();
+ });
+
+ it("disables the input when disabled=true", () => {
+ render();
+ expect(screen.getByLabelText("Secret value").disabled).toBe(true);
+ });
+
+ it("renders with the current value", () => {
+ render();
+ expect(screen.getByLabelText("Secret value").value).toBe("sk-test-key-123");
+ });
+
+ it("renders with the placeholder text", () => {
+ render();
+ expect(screen.getByLabelText("Secret value").getAttribute("placeholder")).toBe("Enter API key");
+ });
+
+ it("renders the RevealToggle child button", () => {
+ render();
+ // KeyValueField renders exactly one button (the RevealToggle)
+ expect(screen.getByRole("button")).toBeTruthy();
+ });
+});
+
+describe("KeyValueField — onChange", () => {
+ afterEach(cleanup);
+
+ it("calls onChange with the new value when user types", () => {
+ const onChange = vi.fn();
+ render();
+ fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "new-value" } });
+ expect(onChange).toHaveBeenCalledWith("new-value");
+ });
+
+ it("trims leading whitespace when user types with leading space", () => {
+ const onChange = vi.fn();
+ render();
+ fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " trimmed" } });
+ expect(onChange).toHaveBeenCalledWith("trimmed");
+ });
+
+ it("trims trailing whitespace when user types with trailing space", () => {
+ const onChange = vi.fn();
+ render();
+ fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "trimmed " } });
+ expect(onChange).toHaveBeenCalledWith("trimmed");
+ });
+
+ it("trims both sides when user types whitespace-surrounded value", () => {
+ const onChange = vi.fn();
+ render();
+ fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " both sides " } });
+ expect(onChange).toHaveBeenCalledWith("both sides");
+ });
+
+ it("does not modify value with no whitespace", () => {
+ const onChange = vi.fn();
+ render();
+ fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "clean-value" } });
+ expect(onChange).toHaveBeenCalledWith("clean-value");
+ });
+});
+
+describe("KeyValueField — auto-hide timer setup", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("sets up a 30s setTimeout when the component mounts with a non-empty value", () => {
+ const setTimeoutSpy = vi.spyOn(global, "setTimeout");
+ render();
+ // No timer should be set initially (revealed=false by default)
+ const callsBeforeInteraction = setTimeoutSpy.mock.calls.length;
+
+ // Simulate reveal (click the only button)
+ act(() => { fireEvent.click(screen.getByRole("button")); });
+
+ // After reveal, a 30s timer should be set
+ const timerCalls = setTimeoutSpy.mock.calls.filter(
+ ([, delay]) => delay === 30_000,
+ );
+ expect(timerCalls.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("clears existing timer when a new toggle happens before auto-hide fires", () => {
+ const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
+ const timerObj = {}; // fake timer ID
+ vi.spyOn(global, "setTimeout").mockImplementation((fn: () => void, delay: number) => {
+ return timerObj;
+ });
+ render();
+
+ // First toggle — reveal
+ act(() => { fireEvent.click(screen.getByRole("button")); });
+
+ // Second toggle — hide (should clear the timer from first toggle)
+ act(() => { fireEvent.click(screen.getByRole("button")); });
+
+ // clearTimeout was called with the timer object
+ expect(clearTimeoutSpy).toHaveBeenCalledWith(timerObj);
+ });
+
+ it("clears timer on unmount", () => {
+ const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
+ const { unmount } = render();
+
+ // Toggle reveal to start the timer
+ act(() => { fireEvent.click(screen.getByRole("button")); });
+
+ unmount();
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+ });
+});
diff --git a/canvas/src/components/ui/__tests__/RevealToggle.test.tsx b/canvas/src/components/ui/__tests__/RevealToggle.test.tsx
new file mode 100644
index 00000000..0a68d454
--- /dev/null
+++ b/canvas/src/components/ui/__tests__/RevealToggle.test.tsx
@@ -0,0 +1,68 @@
+// @vitest-environment jsdom
+/**
+ * Tests for RevealToggle component.
+ *
+ * Covers: eye-icon (hidden) vs eye-off-icon (revealed), onToggle callback,
+ * aria-label (default + custom), title attribute.
+ */
+import { afterEach, describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+import { RevealToggle } from "../RevealToggle";
+
+afterEach(cleanup);
+
+describe("RevealToggle", () => {
+ it("renders as a button", () => {
+ render();
+ expect(screen.getByRole("button")).toBeTruthy();
+ });
+
+ it("uses default aria-label when not provided", () => {
+ render();
+ expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle reveal secret");
+ });
+
+ it("uses custom aria-label when provided", () => {
+ render();
+ expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password");
+ });
+
+ it('title is "Hide value" when revealed', () => {
+ render();
+ expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value");
+ });
+
+ it('title is "Show value" when hidden', () => {
+ render();
+ expect(screen.getByRole("button").getAttribute("title")).toBe("Show value");
+ });
+
+ it("calls onToggle when clicked (revealed=true → should hide)", () => {
+ const onToggle = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole("button"));
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onToggle when clicked (revealed=false → should show)", () => {
+ const onToggle = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole("button"));
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the eye-open SVG (hide icon) when revealed=false", () => {
+ render();
+ const btn = screen.getByRole("button");
+ // The eye SVG contains a circle element; eye-off has a strikethrough line
+ expect(btn.querySelector("circle")).toBeTruthy();
+ expect(btn.querySelectorAll("line")).toHaveLength(0);
+ });
+
+ it("renders the eye-off SVG (show icon) when revealed=true", () => {
+ render();
+ const btn = screen.getByRole("button");
+ // EyeOffIcon has a line (strikethrough) through the eye
+ expect(btn.querySelectorAll("line")).toHaveLength(1);
+ });
+});
diff --git a/canvas/src/components/ui/__tests__/ValidationHint.test.tsx b/canvas/src/components/ui/__tests__/ValidationHint.test.tsx
new file mode 100644
index 00000000..a0a2144c
--- /dev/null
+++ b/canvas/src/components/ui/__tests__/ValidationHint.test.tsx
@@ -0,0 +1,49 @@
+// @vitest-environment jsdom
+/**
+ * Tests for ValidationHint component.
+ *
+ * Covers: null/neutral render, error state (red ⚠ + message), valid state
+ * (green ✓ + "Valid format"), ARIA role="alert" on error.
+ */
+import { afterEach, describe, it, expect } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+import { ValidationHint } from "../ValidationHint";
+
+afterEach(cleanup);
+
+describe("ValidationHint", () => {
+ it("renders nothing when error is null and showValid is false", () => {
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders nothing when error is null and showValid is undefined", () => {
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders error state with ⚠ icon and message", () => {
+ render();
+ const el = screen.getByRole("alert");
+ expect(el.textContent).toContain("⚠");
+ expect(el.textContent).toContain("Key name must be UPPER_SNAKE_CASE");
+ });
+
+ it("renders valid state with ✓ and 'Valid format'", () => {
+ render();
+ const el = screen.getByText("Valid format");
+ expect(el.textContent).toContain("✓");
+ });
+
+ it("prefers error over valid when both are set (error is not null)", () => {
+ // ValidationHint checks error first; showValid is only rendered when error is falsy.
+ render();
+ expect(screen.getByRole("alert")).toBeTruthy();
+ expect(screen.queryByText("Valid format")).toBeNull();
+ });
+
+ it("error alert has role='alert' for screen readers", () => {
+ render();
+ expect(screen.getByRole("alert")).toBeTruthy();
+ });
+});