-
+
{goldSilverCopperFormatter.format(item.totalPrice())}
@@ -63,7 +65,7 @@ export function ShoppingCartItem({
onClick={onDecreaseQuantity}
variant="ghost"
size="icon"
- className="h-9 w-9 p-0 rounded-r-none hover:bg-gray-100"
+ className="h-9 w-9 p-0 rounded-r-none hover:bg-accent"
aria-label={`Decrease quantity of ${item.name()}`}
disabled={item.quantity().isAtMost(CartItem.MIN_QUANTITY)}
data-umami-event={ANALYTICS_CONFIG.events.decreaseQuantity}
@@ -86,7 +88,7 @@ export function ShoppingCartItem({
onClick={onIncreaseQuantity}
variant="ghost"
size="icon"
- className="h-9 w-9 p-0 rounded-l-none hover:bg-gray-100"
+ className="h-9 w-9 p-0 rounded-l-none hover:bg-accent"
aria-label={`Increase quantity of ${item.name()}`}
disabled={item.quantity().isAtLeast(CartItem.MAX_QUANTITY)}
data-umami-event={ANALYTICS_CONFIG.events.increaseQuantity}
@@ -100,7 +102,7 @@ export function ShoppingCartItem({
onClick={onRemoveItem}
variant="ghost"
size="icon"
- className="h-9 w-9 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
+ className="h-9 w-9 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
aria-label={`Remove ${item.name()} from cart`}
data-umami-event={ANALYTICS_CONFIG.events.removeItem}
>
@@ -112,6 +114,6 @@ export function ShoppingCartItem({
-
+
);
}
diff --git a/src/ui/components/cart/__spec__/ShoppingCart.spec.tsx b/src/ui/components/cart/__spec__/ShoppingCart.spec.tsx
index a101e93..0d37640 100644
--- a/src/ui/components/cart/__spec__/ShoppingCart.spec.tsx
+++ b/src/ui/components/cart/__spec__/ShoppingCart.spec.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { RefObject } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -47,8 +47,12 @@ describe("ShoppingCart", () => {
await user.click(addToCartButtons()[0] as HTMLElement);
expect(screen.queryByText("Your inventory is empty")).not.toBeInTheDocument();
+ const cartPanel = screen.getByRole("complementary");
expect(
- screen.getByRole("heading", { level: 4, name: firstProduct?.displayName() }),
+ within(cartPanel).getByRole("heading", {
+ level: 3,
+ name: firstProduct?.displayName(),
+ }),
).toBeInTheDocument();
});
diff --git a/src/ui/primitives/input.tsx b/src/ui/primitives/input.tsx
deleted file mode 100644
index cb0e803..0000000
--- a/src/ui/primitives/input.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Input({ className, type, ...props }: React.ComponentProps<"input">) {
- return (
-
- );
-}
-
-export { Input };
diff --git a/src/ui/primitives/modal.tsx b/src/ui/primitives/modal.tsx
index 9c783f7..ac24243 100644
--- a/src/ui/primitives/modal.tsx
+++ b/src/ui/primitives/modal.tsx
@@ -61,7 +61,7 @@ function ModalContent({
{showCloseButton && (
Close
diff --git a/src/ui/primitives/sheet.tsx b/src/ui/primitives/sheet.tsx
deleted file mode 100644
index c025990..0000000
--- a/src/ui/primitives/sheet.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import * as SheetPrimitive from "@radix-ui/react-dialog";
-import { XIcon } from "lucide-react";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Sheet({ ...props }: React.ComponentProps) {
- return ;
-}
-
-function SheetTrigger({ ...props }: React.ComponentProps) {
- return ;
-}
-
-function SheetClose({ ...props }: React.ComponentProps) {
- return ;
-}
-
-function SheetPortal({ ...props }: React.ComponentProps) {
- return ;
-}
-
-function SheetOverlay({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function SheetContent({
- className,
- children,
- side = "right",
- ...props
-}: React.ComponentProps & {
- side?: "top" | "right" | "bottom" | "left";
-}) {
- return (
-
-
-
- {children}
-
-
- Close
-
-
-
- );
-}
-
-function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function SheetTitle({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function SheetDescription({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-export {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
- SheetTrigger,
-};
diff --git a/src/ui/theme/__spec__/theme.spec.ts b/src/ui/theme/__spec__/theme.spec.ts
new file mode 100644
index 0000000..d846caa
--- /dev/null
+++ b/src/ui/theme/__spec__/theme.spec.ts
@@ -0,0 +1,75 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { applyTheme, getStoredTheme, storeTheme } from "../theme";
+
+describe("theme", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.classList.remove("dark");
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("getStoredTheme", () => {
+ it("should default to system when nothing was stored", () => {
+ expect(getStoredTheme()).toBe("system");
+ });
+
+ it("should return the stored light or dark theme", () => {
+ localStorage.setItem("theme", "dark");
+ expect(getStoredTheme()).toBe("dark");
+
+ localStorage.setItem("theme", "light");
+ expect(getStoredTheme()).toBe("light");
+ });
+
+ it("should treat an unrecognized stored value as system", () => {
+ localStorage.setItem("theme", "sepia");
+ expect(getStoredTheme()).toBe("system");
+ });
+ });
+
+ describe("storeTheme", () => {
+ it("should persist an explicit light or dark choice", () => {
+ storeTheme("dark");
+ expect(localStorage.getItem("theme")).toBe("dark");
+ });
+
+ it("should clear storage when switching back to system", () => {
+ localStorage.setItem("theme", "dark");
+ storeTheme("system");
+ expect(localStorage.getItem("theme")).toBeNull();
+ });
+ });
+
+ describe("applyTheme", () => {
+ it("should add the dark class when the theme is dark", () => {
+ applyTheme("dark");
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
+ });
+
+ it("should remove the dark class when the theme is light", () => {
+ document.documentElement.classList.add("dark");
+ applyTheme("light");
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
+ });
+
+ it("should follow the OS preference when the theme is system", () => {
+ vi.spyOn(window, "matchMedia").mockReturnValue({
+ matches: true,
+ media: "",
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ });
+
+ applyTheme("system");
+
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
+ });
+ });
+});
diff --git a/src/ui/theme/theme.ts b/src/ui/theme/theme.ts
new file mode 100644
index 0000000..cf2e404
--- /dev/null
+++ b/src/ui/theme/theme.ts
@@ -0,0 +1,47 @@
+export type Theme = "light" | "dark" | "system";
+
+const STORAGE_KEY = "theme";
+
+function isStoredTheme(value: string | null): value is Extract {
+ return value === "light" || value === "dark";
+}
+
+export function getStoredTheme(): Theme {
+ if (typeof localStorage === "undefined") return "system";
+
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ return isStoredTheme(stored) ? stored : "system";
+ } catch {
+ return "system";
+ }
+}
+
+export function prefersDark(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ window.matchMedia("(prefers-color-scheme: dark)").matches
+ );
+}
+
+export function applyTheme(theme: Theme): void {
+ if (typeof document === "undefined") return;
+
+ const isDark = theme === "dark" || (theme === "system" && prefersDark());
+ document.documentElement.classList.toggle("dark", isDark);
+}
+
+export function storeTheme(theme: Theme): void {
+ if (typeof localStorage === "undefined") return;
+
+ try {
+ if (theme === "system") {
+ localStorage.removeItem(STORAGE_KEY);
+ } else {
+ localStorage.setItem(STORAGE_KEY, theme);
+ }
+ } catch {
+ // Storage can be unavailable (private browsing, quota); the toggle still works for
+ // the current session via applyTheme, it just won't persist.
+ }
+}
diff --git a/vitest.setup.ts b/vitest.setup.ts
index 30c4c82..4770c31 100644
--- a/vitest.setup.ts
+++ b/vitest.setup.ts
@@ -7,6 +7,22 @@ import { afterEach, vi } from "vitest";
Element.prototype.scrollIntoView = vi.fn();
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
+// jsdom doesn't implement matchMedia either, and the theme toggle and reduced-motion
+// checks both depend on it.
+Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
afterEach(() => {
cleanup();
});