feat(#415): theme fix, AuthDivider, SessionExpiryWarning components
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful

- AUTH-014: Fix theme storage key (jarvis-theme -> mosaic-theme)
- AUTH-016: Create AuthDivider component with customizable text
- AUTH-019: Create SessionExpiryWarning floating banner (PDA-friendly, blue)
- Fix lint errors in LoginForm, OAuthButton from parallel agents
- Sync pnpm-lock.yaml for recharts dependency

Refs #415

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 11:37:31 -06:00
parent 9623a3be97
commit 81b5204258
13 changed files with 899 additions and 4 deletions

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { AuthDivider } from "./AuthDivider";
describe("AuthDivider", (): void => {
it("should render with default text", (): void => {
render(<AuthDivider />);
expect(screen.getByText("or continue with email")).toBeInTheDocument();
});
it("should render with custom text", (): void => {
render(<AuthDivider text="or sign up" />);
expect(screen.getByText("or sign up")).toBeInTheDocument();
});
it("should render a horizontal divider line", (): void => {
const { container } = render(<AuthDivider />);
const line = container.querySelector("span.border-t");
expect(line).toBeInTheDocument();
});
it("should apply uppercase styling to text", (): void => {
const { container } = render(<AuthDivider />);
const textWrapper = container.querySelector(".uppercase");
expect(textWrapper).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,18 @@
interface AuthDividerProps {
text?: string;
}
export function AuthDivider({
text = "or continue with email",
}: AuthDividerProps): React.ReactElement {
return (
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-500">{text}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AuthErrorBanner } from "./AuthErrorBanner";
describe("AuthErrorBanner", (): void => {
it("should render the message text", (): void => {
render(<AuthErrorBanner message="Authentication paused. Please try again when ready." />);
expect(
screen.getByText("Authentication paused. Please try again when ready.")
).toBeInTheDocument();
});
it("should have role alert and aria-live polite for accessibility", (): void => {
render(<AuthErrorBanner message="Unable to connect." />);
const alert = screen.getByRole("alert");
expect(alert).toBeInTheDocument();
expect(alert).toHaveAttribute("aria-live", "polite");
});
it("should render the info icon, not a warning icon", (): void => {
const { container } = render(<AuthErrorBanner message="Test message" />);
// Info icon from lucide-react renders as an SVG
const svgs = container.querySelectorAll("svg");
expect(svgs.length).toBeGreaterThanOrEqual(1);
// The container should use blue styling, not red/yellow
const alert = screen.getByRole("alert");
expect(alert.className).toContain("bg-blue-50");
expect(alert.className).toContain("text-blue-700");
expect(alert.className).not.toContain("red");
expect(alert.className).not.toContain("yellow");
});
it("should render dismiss button when onDismiss is provided", (): void => {
const onDismiss = vi.fn();
render(<AuthErrorBanner message="Test message" onDismiss={onDismiss} />);
const dismissButton = screen.getByLabelText("Dismiss");
expect(dismissButton).toBeInTheDocument();
});
it("should not render dismiss button when onDismiss is not provided", (): void => {
render(<AuthErrorBanner message="Test message" />);
expect(screen.queryByLabelText("Dismiss")).not.toBeInTheDocument();
});
it("should call onDismiss when dismiss button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
const onDismiss = vi.fn();
render(<AuthErrorBanner message="Test message" onDismiss={onDismiss} />);
const dismissButton = screen.getByLabelText("Dismiss");
await user.click(dismissButton);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("should use blue info styling, not red or alarming colors", (): void => {
render(<AuthErrorBanner message="Test" />);
const alert = screen.getByRole("alert");
expect(alert.className).toContain("bg-blue-50");
expect(alert.className).toContain("border-blue-200");
expect(alert.className).toContain("text-blue-700");
});
it("should render all PDA-friendly error messages", (): void => {
const messages = [
"Authentication paused. Please try again when ready.",
"The email and password combination wasn't recognized.",
"Unable to connect. Check your network and try again.",
"The service is taking a break. Please try again in a moment.",
];
for (const message of messages) {
const { unmount } = render(<AuthErrorBanner message={message} />);
expect(screen.getByText(message)).toBeInTheDocument();
unmount();
}
});
});

View File

@@ -0,0 +1,32 @@
"use client";
import type { ReactElement } from "react";
import { Info, X } from "lucide-react";
export interface AuthErrorBannerProps {
message: string;
onDismiss?: () => void;
}
export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): ReactElement {
return (
<div
role="alert"
aria-live="polite"
className="bg-blue-50 border border-blue-200 text-blue-700 rounded-lg p-4 flex items-start gap-3"
>
<Info className="h-5 w-5 flex-shrink-0 mt-0.5" aria-hidden="true" />
<span className="flex-1 text-sm">{message}</span>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="flex-shrink-0 text-blue-500 hover:text-blue-700 transition-colors"
aria-label="Dismiss"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
describe("LoginForm", (): void => {
const mockOnSubmit = vi.fn();
beforeEach((): void => {
mockOnSubmit.mockClear();
});
it("should render email and password fields with labels", (): void => {
render(<LoginForm onSubmit={mockOnSubmit} />);
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
});
it("should render a Continue submit button", (): void => {
render(<LoginForm onSubmit={mockOnSubmit} />);
expect(screen.getByRole("button", { name: "Continue" })).toBeInTheDocument();
});
it("should auto-focus the email input on mount", (): void => {
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText("Email");
expect(document.activeElement).toBe(emailInput);
});
it("should validate email format on submit", async (): Promise<void> => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText("Email");
const passwordInput = screen.getByLabelText("Password");
const submitButton = screen.getByRole("button", { name: "Continue" });
await user.type(emailInput, "invalid-email");
await user.type(passwordInput, "password123");
await user.click(submitButton);
expect(screen.getByText("Please enter a valid email address.")).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it("should validate non-empty password on submit", async (): Promise<void> => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText("Email");
const submitButton = screen.getByRole("button", { name: "Continue" });
await user.type(emailInput, "user@example.com");
await user.click(submitButton);
expect(screen.getByText("Password is recommended.")).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it("should call onSubmit with email and password when valid", async (): Promise<void> => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText("Email");
const passwordInput = screen.getByLabelText("Password");
const submitButton = screen.getByRole("button", { name: "Continue" });
await user.type(emailInput, "user@example.com");
await user.type(passwordInput, "password123");
await user.click(submitButton);
expect(mockOnSubmit).toHaveBeenCalledWith("user@example.com", "password123");
});
it("should show loading state with spinner and Signing in text", (): void => {
render(<LoginForm onSubmit={mockOnSubmit} isLoading={true} />);
expect(screen.getByText("Signing in...")).toBeInTheDocument();
expect(screen.queryByText("Continue")).not.toBeInTheDocument();
});
it("should disable inputs when loading", (): void => {
render(<LoginForm onSubmit={mockOnSubmit} isLoading={true} />);
expect(screen.getByLabelText("Email")).toBeDisabled();
expect(screen.getByLabelText("Password")).toBeDisabled();
expect(screen.getByRole("button")).toBeDisabled();
});
it("should display error message when error prop is provided", (): void => {
render(
<LoginForm
onSubmit={mockOnSubmit}
error="The email and password combination wasn't recognized."
/>
);
expect(
screen.getByText("The email and password combination wasn't recognized.")
).toBeInTheDocument();
});
it("should dismiss error when dismiss button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(
<LoginForm
onSubmit={mockOnSubmit}
error="Authentication paused. Please try again when ready."
/>
);
expect(
screen.getByText("Authentication paused. Please try again when ready.")
).toBeInTheDocument();
const dismissButton = screen.getByLabelText("Dismiss");
await user.click(dismissButton);
expect(
screen.queryByText("Authentication paused. Please try again when ready.")
).not.toBeInTheDocument();
});
it("should have htmlFor on email label pointing to email input", (): void => {
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailLabel = screen.getByText("Email");
const emailInput = screen.getByLabelText("Email");
expect(emailLabel).toHaveAttribute("for", emailInput.id);
});
it("should have htmlFor on password label pointing to password input", (): void => {
render(<LoginForm onSubmit={mockOnSubmit} />);
const passwordLabel = screen.getByText("Password");
const passwordInput = screen.getByLabelText("Password");
expect(passwordLabel).toHaveAttribute("for", passwordInput.id);
});
it("should clear email validation error when user types a valid email", async (): Promise<void> => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText("Email");
const submitButton = screen.getByRole("button", { name: "Continue" });
// Trigger validation error
await user.type(emailInput, "invalid");
await user.click(submitButton);
expect(screen.getByText("Please enter a valid email address.")).toBeInTheDocument();
// Fix the email
await user.clear(emailInput);
await user.type(emailInput, "user@example.com");
await waitFor((): void => {
expect(screen.queryByText("Please enter a valid email address.")).not.toBeInTheDocument();
});
});
it("should set aria-invalid on email input when validation fails", async (): Promise<void> => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText("Email");
const submitButton = screen.getByRole("button", { name: "Continue" });
await user.type(emailInput, "invalid");
await user.click(submitButton);
expect(emailInput).toHaveAttribute("aria-invalid", "true");
});
});

View File

@@ -0,0 +1,172 @@
"use client";
import { useRef, useEffect, useState, useCallback } from "react";
import type { ReactElement } from "react";
import { Loader2 } from "lucide-react";
import { AuthErrorBanner } from "./AuthErrorBanner";
export interface LoginFormProps {
onSubmit: (email: string, password: string) => void | Promise<void>;
isLoading?: boolean;
error?: string | null;
}
export function LoginForm({
onSubmit,
isLoading = false,
error = null,
}: LoginFormProps): ReactElement {
const emailRef = useRef<HTMLInputElement>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [dismissedError, setDismissedError] = useState(false);
useEffect((): void => {
emailRef.current?.focus();
}, []);
// Reset dismissed state when a new error comes in
useEffect((): void => {
if (error) {
setDismissedError(false);
}
}, [error]);
const validateEmail = useCallback((value: string): boolean => {
if (!value.includes("@")) {
setEmailError("Please enter a valid email address.");
return false;
}
setEmailError(null);
return true;
}, []);
const validatePassword = useCallback((value: string): boolean => {
if (value.length === 0) {
setPasswordError("Password is recommended.");
return false;
}
setPasswordError(null);
return true;
}, []);
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault();
const isEmailValid = validateEmail(email);
const isPasswordValid = validatePassword(password);
if (!isEmailValid || !isPasswordValid) {
return;
}
void onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
{error && !dismissedError && (
<AuthErrorBanner
message={error}
onDismiss={(): void => {
setDismissedError(true);
}}
/>
)}
<div>
<label htmlFor="login-email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
ref={emailRef}
id="login-email"
type="email"
value={email}
onChange={(e): void => {
setEmail(e.target.value);
if (emailError) {
validateEmail(e.target.value);
}
}}
disabled={isLoading}
autoComplete="email"
className={[
"w-full px-3 py-2 border rounded-md",
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
emailError ? "border-blue-400" : "border-gray-300",
isLoading ? "opacity-50" : "",
]
.filter(Boolean)
.join(" ")}
aria-invalid={emailError ? "true" : "false"}
aria-describedby={emailError ? "login-email-error" : undefined}
/>
{emailError && (
<p id="login-email-error" className="mt-1 text-sm text-blue-600" role="alert">
{emailError}
</p>
)}
</div>
<div>
<label htmlFor="login-password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="login-password"
type="password"
value={password}
onChange={(e): void => {
setPassword(e.target.value);
if (passwordError) {
validatePassword(e.target.value);
}
}}
disabled={isLoading}
autoComplete="current-password"
className={[
"w-full px-3 py-2 border rounded-md",
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
passwordError ? "border-blue-400" : "border-gray-300",
isLoading ? "opacity-50" : "",
]
.filter(Boolean)
.join(" ")}
aria-invalid={passwordError ? "true" : "false"}
aria-describedby={passwordError ? "login-password-error" : undefined}
/>
{passwordError && (
<p id="login-password-error" className="mt-1 text-sm text-blue-600" role="alert">
{passwordError}
</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className={[
"w-full inline-flex items-center justify-center gap-2",
"rounded-md px-4 py-2 text-base font-medium",
"bg-blue-600 text-white hover:bg-blue-700",
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
isLoading ? "opacity-50 pointer-events-none" : "",
]
.filter(Boolean)
.join(" ")}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
<span>Signing in...</span>
</>
) : (
<span>Continue</span>
)}
</button>
</form>
);
}

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { OAuthButton } from "./OAuthButton";
describe("OAuthButton", (): void => {
const defaultProps = {
providerName: "Authentik",
providerId: "authentik",
onClick: vi.fn(),
};
it("should render with provider name", (): void => {
render(<OAuthButton {...defaultProps} />);
expect(screen.getByText("Continue with Authentik")).toBeInTheDocument();
});
it("should have full width styling", (): void => {
render(<OAuthButton {...defaultProps} />);
const button = screen.getByRole("button");
expect(button.className).toContain("w-full");
});
it("should call onClick when clicked", async (): Promise<void> => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<OAuthButton {...defaultProps} onClick={onClick} />);
const button = screen.getByRole("button");
await user.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("should show loading state with spinner and Connecting text", (): void => {
render(<OAuthButton {...defaultProps} isLoading={true} />);
expect(screen.getByText("Connecting...")).toBeInTheDocument();
expect(screen.queryByText("Continue with Authentik")).not.toBeInTheDocument();
});
it("should be disabled when isLoading is true", (): void => {
render(<OAuthButton {...defaultProps} isLoading={true} />);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
});
it("should be disabled when disabled prop is true", (): void => {
render(<OAuthButton {...defaultProps} disabled={true} />);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
});
it("should have reduced opacity when disabled", (): void => {
render(<OAuthButton {...defaultProps} disabled={true} />);
const button = screen.getByRole("button");
expect(button.className).toContain("opacity-50");
expect(button.className).toContain("pointer-events-none");
});
it("should have aria-label with provider name", (): void => {
render(<OAuthButton {...defaultProps} />);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-label", "Continue with Authentik");
});
it("should have aria-label Connecting when loading", (): void => {
render(<OAuthButton {...defaultProps} isLoading={true} />);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-label", "Connecting");
});
it("should render a spinner SVG when loading", (): void => {
const { container } = render(<OAuthButton {...defaultProps} isLoading={true} />);
const spinner = container.querySelector("svg");
expect(spinner).toBeInTheDocument();
expect(spinner?.getAttribute("class")).toContain("animate-spin");
});
it("should not call onClick when disabled", async (): Promise<void> => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<OAuthButton {...defaultProps} onClick={onClick} disabled={true} />);
const button = screen.getByRole("button");
await user.click(button);
expect(onClick).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,49 @@
"use client";
import type { ReactElement } from "react";
import { Loader2 } from "lucide-react";
export interface OAuthButtonProps {
providerName: string;
providerId: string;
onClick: () => void;
isLoading?: boolean;
disabled?: boolean;
}
export function OAuthButton({
providerName,
onClick,
isLoading = false,
disabled = false,
}: OAuthButtonProps): ReactElement {
const isDisabled = disabled || isLoading;
return (
<button
type="button"
role="button"
onClick={onClick}
disabled={isDisabled}
aria-label={isLoading ? "Connecting" : `Continue with ${providerName}`}
className={[
"w-full inline-flex items-center justify-center gap-2",
"rounded-md px-4 py-2 text-base font-medium",
"bg-blue-600 text-white hover:bg-blue-700",
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
isDisabled ? "opacity-50 pointer-events-none" : "",
]
.filter(Boolean)
.join(" ")}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
<span>Connecting...</span>
</>
) : (
<span>Continue with {providerName}</span>
)}
</button>
);
}

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SessionExpiryWarning } from "./SessionExpiryWarning";
describe("SessionExpiryWarning", (): void => {
it("should render with minutes remaining", (): void => {
render(<SessionExpiryWarning minutesRemaining={5} />);
expect(screen.getByText(/your session will end in 5 minutes/i)).toBeInTheDocument();
});
it("should use singular 'minute' when 1 minute remaining", (): void => {
render(<SessionExpiryWarning minutesRemaining={1} />);
expect(screen.getByText(/your session will end in 1 minute\./i)).toBeInTheDocument();
});
it("should show extend button when onExtend is provided", (): void => {
const onExtend = vi.fn();
render(<SessionExpiryWarning minutesRemaining={3} onExtend={onExtend} />);
expect(screen.getByText("Extend Session")).toBeInTheDocument();
});
it("should not show extend button when onExtend is not provided", (): void => {
render(<SessionExpiryWarning minutesRemaining={3} />);
expect(screen.queryByText("Extend Session")).not.toBeInTheDocument();
});
it("should call onExtend when extend button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
const onExtend = vi.fn();
render(<SessionExpiryWarning minutesRemaining={3} onExtend={onExtend} />);
await user.click(screen.getByText("Extend Session"));
expect(onExtend).toHaveBeenCalledOnce();
});
it("should show dismiss button when onDismiss is provided", (): void => {
const onDismiss = vi.fn();
render(<SessionExpiryWarning minutesRemaining={3} onDismiss={onDismiss} />);
expect(screen.getByLabelText("Dismiss")).toBeInTheDocument();
});
it("should not show dismiss button when onDismiss is not provided", (): void => {
render(<SessionExpiryWarning minutesRemaining={3} />);
expect(screen.queryByLabelText("Dismiss")).not.toBeInTheDocument();
});
it("should call onDismiss when dismiss button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
const onDismiss = vi.fn();
render(<SessionExpiryWarning minutesRemaining={3} onDismiss={onDismiss} />);
await user.click(screen.getByLabelText("Dismiss"));
expect(onDismiss).toHaveBeenCalledOnce();
});
it("should have role='status' for accessibility", (): void => {
render(<SessionExpiryWarning minutesRemaining={5} />);
expect(screen.getByRole("status")).toBeInTheDocument();
});
it("should have aria-live='polite' for screen readers", (): void => {
render(<SessionExpiryWarning minutesRemaining={5} />);
const statusElement = screen.getByRole("status");
expect(statusElement).toHaveAttribute("aria-live", "polite");
});
it("should use blue theme (not red) for PDA-friendly design", (): void => {
render(<SessionExpiryWarning minutesRemaining={5} />);
const statusElement = screen.getByRole("status");
expect(statusElement.className).toContain("bg-blue-50");
expect(statusElement.className).toContain("border-blue-200");
});
it("should include saving work reminder in message", (): void => {
render(<SessionExpiryWarning minutesRemaining={5} />);
expect(screen.getByText(/consider saving your work/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,50 @@
import { Info, X } from "lucide-react";
interface SessionExpiryWarningProps {
minutesRemaining: number;
onExtend?: () => void;
onDismiss?: () => void;
}
export function SessionExpiryWarning({
minutesRemaining,
onExtend,
onDismiss,
}: SessionExpiryWarningProps): React.ReactElement {
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-50 flex max-w-sm items-start gap-3 rounded-lg border border-blue-200 bg-blue-50 p-4 shadow-lg"
>
<Info className="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-500" aria-hidden="true" />
<div className="flex-1">
<p className="text-sm text-blue-700">
Your session will end in {minutesRemaining}{" "}
{minutesRemaining === 1 ? "minute" : "minutes"}. Consider saving your work.
</p>
<div className="mt-2 flex gap-2">
{onExtend ? (
<button
type="button"
onClick={onExtend}
className="rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
>
Extend Session
</button>
) : null}
</div>
</div>
{onDismiss ? (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="flex-shrink-0 text-blue-400 hover:text-blue-600 focus:outline-none"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { ThemeProvider, useTheme } from "./ThemeProvider";
function ThemeConsumer(): React.JSX.Element {
const { theme, resolvedTheme, setTheme, toggleTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="resolved">{resolvedTheme}</span>
<button
onClick={() => {
setTheme("light");
}}
>
Set Light
</button>
<button
onClick={() => {
setTheme("dark");
}}
>
Set Dark
</button>
<button
onClick={() => {
toggleTheme();
}}
>
Toggle
</button>
</div>
);
}
describe("ThemeProvider", (): void => {
let mockMatchMedia: ReturnType<typeof vi.fn>;
beforeEach((): void => {
localStorage.clear();
document.documentElement.classList.remove("light", "dark");
mockMatchMedia = vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
Object.defineProperty(window, "matchMedia", {
writable: true,
value: mockMatchMedia,
});
});
afterEach((): void => {
vi.restoreAllMocks();
});
it("should use 'mosaic-theme' as storage key", (): void => {
localStorage.setItem("mosaic-theme", "light");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId("theme")).toHaveTextContent("light");
});
it("should NOT read from old 'jarvis-theme' storage key", (): void => {
localStorage.setItem("jarvis-theme", "light");
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
// Should default to system, not read from jarvis-theme
expect(screen.getByTestId("theme")).toHaveTextContent("system");
});
it("should store theme under 'mosaic-theme' key", (): void => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
act(() => {
screen.getByText("Set Light").click();
});
expect(localStorage.getItem("mosaic-theme")).toBe("light");
expect(localStorage.getItem("jarvis-theme")).toBeNull();
});
it("should render children", (): void => {
render(
<ThemeProvider>
<div data-testid="child">Hello</div>
</ThemeProvider>
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("should throw when useTheme is used outside provider", (): void => {
// Suppress console.error for expected error
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
// Intentionally empty
});
expect(() => {
render(<ThemeConsumer />);
}).toThrow("useTheme must be used within a ThemeProvider");
consoleSpy.mockRestore();
});
});

View File

@@ -13,7 +13,7 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | null>(null);
const STORAGE_KEY = "jarvis-theme";
const STORAGE_KEY = "mosaic-theme";
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "dark";

18
pnpm-lock.yaml generated
View File

@@ -7408,7 +7408,7 @@ snapshots:
chalk: 5.6.2
commander: 12.1.0
dotenv: 17.2.4
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
open: 10.2.0
pg: 8.17.2
prettier: 3.8.1
@@ -10410,7 +10410,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
better-sqlite3: 12.6.2
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -10435,7 +10435,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
better-sqlite3: 12.6.2
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -11229,6 +11229,17 @@ snapshots:
dotenv@17.2.4: {}
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
'@types/pg': 8.16.0
better-sqlite3: 12.6.2
kysely: 0.28.10
pg: 8.17.2
postgres: 3.4.8
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -11239,6 +11250,7 @@ snapshots:
pg: 8.17.2
postgres: 3.4.8
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
optional: true
dunder-proto@1.0.1:
dependencies: