diff --git a/package.json b/package.json
index cae895c..eb4a62f 100644
--- a/package.json
+++ b/package.json
@@ -1,41 +1,42 @@
{
"name": "forge",
- "version": "1.0.0",
+ "version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
- "test": "npm run test:unit && npm run test:integration",
- "test:unit": "vitest run tests/unit",
- "test:integration": "vitest run tests/integration",
- "test:e2e": "playwright test"
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage"
},
"dependencies": {
- "@hookform/resolvers": "^3.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "lucide-react": "^0.460.0",
- "next": "14.2.18",
- "react": "18.3.1",
- "react-dom": "18.3.1",
- "react-hook-form": "^7.53.2",
- "tailwind-merge": "^2.5.4",
- "zod": "^3.23.8"
+ "lucide-react": "^0.468.0",
+ "next": "^15.1.6",
+ "next-themes": "^0.4.4",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "tailwind-merge": "^2.5.5",
+ "zod": "^3.24.1"
},
"devDependencies": {
- "@playwright/test": "^1.49.1",
"@testing-library/jest-dom": "^6.6.3",
- "@testing-library/react": "^16.0.1",
- "@types/node": "^22.9.0",
- "@types/react": "^18.3.12",
- "@types/react-dom": "^18.3.1",
+ "@testing-library/react": "^16.1.0",
+ "@types/node": "^22.10.2",
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@vitest/coverage-v8": "^2.1.8",
"autoprefixer": "^10.4.20",
+ "eslint": "^9.17.0",
+ "eslint-config-next": "^15.1.6",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
- "tailwindcss": "^3.4.16",
- "typescript": "^5.6.3",
- "vitest": "^2.1.5"
+ "tailwindcss": "^3.4.17",
+ "tailwindcss-animate": "^1.0.7",
+ "typescript": "^5.7.2",
+ "vitest": "^2.1.8"
}
}
diff --git a/src/app/globals-css.test.ts b/src/app/globals-css.test.ts
new file mode 100644
index 0000000..b4003c1
--- /dev/null
+++ b/src/app/globals-css.test.ts
@@ -0,0 +1,34 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+describe("globals.css", () => {
+ it("defines Tailwind layers and root/dark theme variables", async () => {
+ const cssPath = path.resolve(process.cwd(), "src/app/globals.css");
+ const css = await readFile(cssPath, "utf8");
+
+ expect(css).toContain("@tailwind base;");
+ expect(css).toContain("@tailwind components;");
+ expect(css).toContain("@tailwind utilities;");
+
+ expect(css).toContain(":root {");
+ expect(css).toContain(".dark {");
+
+ expect(css).toContain("--background:");
+ expect(css).toContain("--foreground:");
+ expect(css).toContain("--primary:");
+ expect(css).toContain("--secondary:");
+ expect(css).toContain("--accent:");
+ expect(css).toContain("--destructive:");
+ expect(css).toContain("--chart-1:");
+ expect(css).toContain("--chart-5:");
+ });
+
+ it("applies global border and body typography utilities", async () => {
+ const cssPath = path.resolve(process.cwd(), "src/app/globals.css");
+ const css = await readFile(cssPath, "utf8");
+
+ expect(css).toContain("@apply border-border;");
+ expect(css).toContain("@apply bg-background text-foreground font-sans antialiased;");
+ });
+});
diff --git a/src/app/globals.css b/src/app/globals.css
index e432ad9..e9f30c9 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2,15 +2,66 @@
@tailwind components;
@tailwind utilities;
-:root {
- color-scheme: dark;
-}
+@layer base {
+ :root {
+ --background: 210 40% 98%;
+ --foreground: 222.2 47.4% 11.2%;
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 47.4% 11.2%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 47.4% 11.2%;
+ --primary: 218.3 79.2% 53.1%;
+ --primary-foreground: 210 40% 98%;
+ --secondary: 179.1 84.1% 35.1%;
+ --secondary-foreground: 210 40% 98%;
+ --muted: 214.3 31.8% 91.4%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+ --accent: 37.7 92.1% 50.2%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+ --destructive: 0 72.2% 50.6%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 218.3 79.2% 53.1%;
+ --chart-1: 218.3 79.2% 53.1%;
+ --chart-2: 179.1 84.1% 35.1%;
+ --chart-3: 37.7 92.1% 50.2%;
+ --chart-4: 262.1 83.3% 57.8%;
+ --chart-5: 0 72.2% 50.6%;
+ }
-html,
-body {
- min-height: 100%;
-}
+ .dark {
+ --background: 222.2 47.4% 11.2%;
+ --foreground: 210 40% 98%;
+ --card: 222.2 47.4% 14%;
+ --card-foreground: 210 40% 98%;
+ --popover: 222.2 47.4% 14%;
+ --popover-foreground: 210 40% 98%;
+ --primary: 213.1 93.9% 67.8%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+ --secondary: 173.4 80.4% 48%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+ --accent: 43.3 96.4% 56.3%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+ --destructive: 0 91.2% 71.4%;
+ --destructive-foreground: 222.2 47.4% 11.2%;
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 213.1 93.9% 67.8%;
+ --chart-1: 213.1 93.9% 67.8%;
+ --chart-2: 173.4 80.4% 48%;
+ --chart-3: 43.3 96.4% 56.3%;
+ --chart-4: 258.3 89.5% 74.7%;
+ --chart-5: 0 91.2% 71.4%;
+ }
+
+ * {
+ @apply border-border;
+ }
-body {
- @apply bg-background text-foreground antialiased;
+ body {
+ @apply bg-background text-foreground font-sans antialiased;
+ }
}
diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx
new file mode 100644
index 0000000..ae886f0
--- /dev/null
+++ b/src/app/layout.test.tsx
@@ -0,0 +1,34 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("next/font/google", () => ({
+ Inter: () => ({ variable: "--font-inter-mock" })
+}));
+
+vi.mock("@/lib/config/env", () => ({
+ env: {
+ NEXT_PUBLIC_APP_NAME: "Forge Test App"
+ }
+}));
+
+import RootLayout, { metadata } from "@/app/layout";
+
+describe("app layout", () => {
+ it("exports metadata with app name title", () => {
+ expect(metadata.title).toBe("Forge Test App");
+ expect(metadata.description).toBe("Forge client portal");
+ });
+
+ it("renders html/body shell with expected classes", () => {
+ const tree = RootLayout({ children:
});
+
+ expect(tree.type).toBe("html");
+ expect(tree.props.lang).toBe("en");
+ expect(tree.props.className).toBe("--font-inter-mock");
+
+ const body = tree.props.children;
+ expect(body.type).toBe("body");
+ expect(body.props.className).toContain("font-sans");
+ expect(body.props.className).toContain("antialiased");
+ expect(body.props.children.props.id).toBe("child");
+ });
+});
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e8e96a0..e407c59 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,9 +1,9 @@
import type { Metadata } from "next";
-import { Manrope } from "next/font/google";
+import { Inter } from "next/font/google";
import "@/app/globals.css";
import { env } from "@/lib/config/env";
-const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope" });
+const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: env.NEXT_PUBLIC_APP_NAME,
@@ -12,8 +12,8 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }): JSX.Element {
return (
-
- {children}
+
+ {children}
);
}
diff --git a/src/config/__tests__/package-json.test.ts b/src/config/__tests__/package-json.test.ts
new file mode 100644
index 0000000..a9686cd
--- /dev/null
+++ b/src/config/__tests__/package-json.test.ts
@@ -0,0 +1,73 @@
+import fs from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+type PackageJson = {
+ name: string;
+ version: string;
+ private: boolean;
+ scripts: Record;
+ dependencies: Record;
+ devDependencies: Record;
+};
+
+const packageJsonPath = path.resolve(process.cwd(), "package.json");
+const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as PackageJson;
+
+describe("package.json", () => {
+ it("defines expected package identity", () => {
+ expect(packageJson.name).toBe("forge");
+ expect(packageJson.version).toBe("0.1.0");
+ expect(packageJson.private).toBe(true);
+ });
+
+ it("exposes expected app scripts", () => {
+ expect(packageJson.scripts).toMatchObject({
+ dev: "next dev",
+ build: "next build",
+ start: "next start",
+ lint: "next lint",
+ test: "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage"
+ });
+ });
+
+ it("includes runtime dependencies needed by app and UI", () => {
+ const deps = packageJson.dependencies;
+
+ expect(deps).toEqual(
+ expect.objectContaining({
+ next: expect.stringMatching(/^\^/),
+ react: expect.stringMatching(/^\^/),
+ "react-dom": expect.stringMatching(/^\^/),
+ zod: expect.stringMatching(/^\^/),
+ "next-themes": expect.stringMatching(/^\^/),
+ "lucide-react": expect.stringMatching(/^\^/),
+ "class-variance-authority": expect.stringMatching(/^\^/),
+ clsx: expect.stringMatching(/^\^/),
+ "tailwind-merge": expect.stringMatching(/^\^/)
+ })
+ );
+ });
+
+ it("includes required testing/tooling dev dependencies", () => {
+ const devDeps = packageJson.devDependencies;
+
+ expect(devDeps).toEqual(
+ expect.objectContaining({
+ vitest: expect.stringMatching(/^\^/),
+ jsdom: expect.stringMatching(/^\^/),
+ "@testing-library/react": expect.stringMatching(/^\^/),
+ "@testing-library/jest-dom": expect.stringMatching(/^\^/),
+ "@vitest/coverage-v8": expect.stringMatching(/^\^/),
+ typescript: expect.stringMatching(/^\^/),
+ eslint: expect.stringMatching(/^\^/),
+ "eslint-config-next": expect.stringMatching(/^\^/),
+ tailwindcss: expect.stringMatching(/^\^/),
+ postcss: expect.stringMatching(/^\^/),
+ autoprefixer: expect.stringMatching(/^\^/)
+ })
+ );
+ });
+});
diff --git a/src/config/__tests__/tailwind-config.test.ts b/src/config/__tests__/tailwind-config.test.ts
new file mode 100644
index 0000000..2e50fd2
--- /dev/null
+++ b/src/config/__tests__/tailwind-config.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import config from "../../../../tailwind.config";
+
+describe("tailwind.config", () => {
+ it("uses class-based dark mode", () => {
+ expect(config.darkMode).toEqual(["class"]);
+ });
+
+ it("scans app/component/hook/lib source globs", () => {
+ expect(config.content).toEqual([
+ "./src/app/**/*.{ts,tsx}",
+ "./src/components/**/*.{ts,tsx}",
+ "./src/hooks/**/*.{ts,tsx}",
+ "./src/lib/**/*.{ts,tsx}"
+ ]);
+ });
+
+ it("defines key extended color tokens", () => {
+ const colors = config.theme?.extend?.colors as Record;
+
+ expect(colors.border).toBe("hsl(var(--border))");
+ expect(colors.input).toBe("hsl(var(--border))");
+ expect(colors.ring).toBe("hsl(var(--primary))");
+ expect(colors.background).toBe("hsl(var(--background))");
+ expect(colors.foreground).toBe("hsl(var(--foreground))");
+ expect(colors.primary).toEqual({
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))"
+ });
+ expect(colors.secondary).toEqual({
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))"
+ });
+ expect(colors.destructive).toEqual({
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))"
+ });
+ expect(colors["chart-1"]).toBe("hsl(var(--chart-1))");
+ expect(colors["chart-5"]).toBe("hsl(var(--chart-5))");
+ });
+
+ it("defines custom radii and font family", () => {
+ const extend = config.theme?.extend as {
+ borderRadius: Record;
+ fontFamily: Record;
+ };
+
+ expect(extend.borderRadius).toEqual({
+ lg: "0.5rem",
+ md: "0.375rem",
+ xl: "0.75rem"
+ });
+
+ expect(extend.fontFamily.sans).toEqual([
+ "var(--font-inter)",
+ "Inter",
+ "system-ui",
+ "sans-serif"
+ ]);
+ });
+
+ it("registers tailwindcss-animate plugin", () => {
+ expect(Array.isArray(config.plugins)).toBe(true);
+ expect(config.plugins).toHaveLength(1);
+ expect(typeof config.plugins?.[0]).toBe("function");
+ });
+});
diff --git a/src/config/__tests__/vitest-config.test.ts b/src/config/__tests__/vitest-config.test.ts
new file mode 100644
index 0000000..bae2a88
--- /dev/null
+++ b/src/config/__tests__/vitest-config.test.ts
@@ -0,0 +1,51 @@
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import config from "../../../../vitest.config";
+
+describe("vitest.config", () => {
+ it("uses src alias mapped to an absolute src path", () => {
+ const aliasPath = config.resolve?.alias?.["@"];
+
+ expect(typeof aliasPath).toBe("string");
+ expect(path.isAbsolute(aliasPath as string)).toBe(true);
+ expect((aliasPath as string).endsWith(`${path.sep}src`)).toBe(true);
+ });
+
+ it("configures jsdom test environment and setup file", () => {
+ expect(config.test?.environment).toBe("jsdom");
+ expect(config.test?.globals).toBe(true);
+ expect(config.test?.setupFiles).toEqual(["./src/test/setup-tests.ts"]);
+ });
+
+ it("includes and excludes expected test patterns", () => {
+ expect(config.test?.include).toEqual(["src/**/*.test.ts", "src/**/*.test.tsx"]);
+ expect(config.test?.exclude).toEqual([
+ "node_modules",
+ ".next",
+ "dist",
+ "coverage",
+ "e2e"
+ ]);
+ });
+
+ it("enables consistent mock lifecycle options", () => {
+ expect(config.test?.clearMocks).toBe(true);
+ expect(config.test?.restoreMocks).toBe(true);
+ expect(config.test?.mockReset).toBe(true);
+ });
+
+ it("defines coverage provider/reporters and source include rules", () => {
+ const coverage = config.test?.coverage;
+
+ expect(coverage?.provider).toBe("v8");
+ expect(coverage?.reporter).toEqual(["text", "html", "lcov"]);
+ expect(coverage?.reportsDirectory).toBe("./coverage");
+ expect(coverage?.include).toEqual(["src/**/*.{ts,tsx}"]);
+ expect(coverage?.exclude).toEqual([
+ "src/**/*.d.ts",
+ "src/**/__tests__/**",
+ "src/test/**",
+ "src/**/index.ts"
+ ]);
+ });
+});
diff --git a/src/hooks/__tests__/use-sse.test.ts b/src/hooks/__tests__/use-sse.test.ts
new file mode 100644
index 0000000..1c7f719
--- /dev/null
+++ b/src/hooks/__tests__/use-sse.test.ts
@@ -0,0 +1,270 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useSse } from "@/hooks/use-sse";
+import type { PipelineEvent } from "@/lib/types/domain";
+
+class MockEventSource {
+ static instances: MockEventSource[] = [];
+ static throwOnNext = false;
+
+ public readonly url: string;
+ public onmessage: ((event: MessageEvent) => void) | null = null;
+ public onerror: ((event: Event) => void) | null = null;
+ public closed = false;
+
+ constructor(url: string) {
+ if (MockEventSource.throwOnNext) {
+ MockEventSource.throwOnNext = false;
+ throw new Error("EventSource init failed");
+ }
+
+ this.url = url;
+ MockEventSource.instances.push(this);
+ }
+
+ close(): void {
+ this.closed = true;
+ }
+
+ emitMessage(data: unknown, lastEventId = ""): void {
+ const event = { data: JSON.stringify(data), lastEventId } as MessageEvent;
+ this.onmessage?.(event);
+ }
+
+ emitRawMessage(rawData: string, lastEventId = ""): void {
+ const event = { data: rawData, lastEventId } as MessageEvent;
+ this.onmessage?.(event);
+ }
+
+ emitError(): void {
+ this.onerror?.(new Event("error"));
+ }
+}
+
+describe("useSse", () => {
+ beforeEach((): void => {
+ vi.useFakeTimers();
+ MockEventSource.instances = [];
+ MockEventSource.throwOnNext = false;
+ globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
+ });
+
+ afterEach((): void => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ });
+
+ it("opens an initial SSE connection for the project", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ renderHook(() => useSse("project-1", onEvent));
+
+ expect(MockEventSource.instances).toHaveLength(1);
+ expect(MockEventSource.instances[0]?.url).toBe("/api/projects/project-1/events");
+ });
+
+ it("parses incoming messages and forwards events", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ renderHook(() => useSse("project-1", onEvent));
+
+ const source = MockEventSource.instances[0];
+ const event = {
+ type: "stats",
+ payload: {
+ tokensUsed: 10,
+ durationMs: 100
+ }
+ } as unknown as PipelineEvent;
+
+ act((): void => {
+ source?.emitMessage(event, "evt-1");
+ });
+
+ expect(onEvent).toHaveBeenCalledTimes(1);
+ expect(onEvent).toHaveBeenCalledWith(event);
+ });
+
+ it("ignores malformed JSON payloads without crashing", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ renderHook(() => useSse("project-1", onEvent));
+
+ const source = MockEventSource.instances[0];
+
+ act((): void => {
+ source?.emitRawMessage("not-json");
+ });
+
+ expect(onEvent).not.toHaveBeenCalled();
+ });
+
+ it("reconnects with message lastEventId after stream error", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ renderHook(() => useSse("project-1", onEvent));
+
+ const firstSource = MockEventSource.instances[0];
+
+ act((): void => {
+ firstSource?.emitMessage(
+ { type: "log", payload: { message: "ok" } } as unknown as PipelineEvent,
+ "evt-42"
+ );
+ firstSource?.emitError();
+ });
+
+ act((): void => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(MockEventSource.instances).toHaveLength(2);
+ expect(MockEventSource.instances[1]?.url).toBe(
+ "/api/projects/project-1/events?lastEventId=evt-42"
+ );
+ });
+
+ it("falls back to payload id when MessageEvent.lastEventId is empty", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ renderHook(() => useSse("project-1", onEvent));
+
+ const firstSource = MockEventSource.instances[0];
+ const payloadWithId = {
+ id: "payload-evt-9",
+ type: "log",
+ payload: { message: "ok" }
+ } as unknown as PipelineEvent;
+
+ act((): void => {
+ firstSource?.emitMessage(payloadWithId, "");
+ firstSource?.emitError();
+ });
+
+ act((): void => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(MockEventSource.instances).toHaveLength(2);
+ expect(MockEventSource.instances[1]?.url).toBe(
+ "/api/projects/project-1/events?lastEventId=payload-evt-9"
+ );
+ });
+
+ it("uses exponential backoff and caps retry delay at 5000ms", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ renderHook(() => useSse("project-1", onEvent));
+
+ const first = MockEventSource.instances[0];
+
+ act(() => {
+ first?.emitError();
+ vi.advanceTimersByTime(1000); // reconnect #1 (next delay 2000)
+ });
+
+ const second = MockEventSource.instances[1];
+ act(() => {
+ second?.emitError();
+ vi.advanceTimersByTime(2000); // reconnect #2 (next delay 4000)
+ });
+
+ const third = MockEventSource.instances[2];
+ act(() => {
+ third?.emitError();
+ vi.advanceTimersByTime(4000); // reconnect #3 (next delay 5000 cap)
+ });
+
+ const fourth = MockEventSource.instances[3];
+ act(() => {
+ fourth?.emitError();
+ vi.advanceTimersByTime(5000); // reconnect #4 (still capped)
+ });
+
+ expect(MockEventSource.instances).toHaveLength(5);
+ });
+
+ it("retries when EventSource constructor throws", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ MockEventSource.throwOnNext = true;
+ renderHook(() => useSse("project-1", onEvent));
+
+ // First connect fails before an instance is tracked.
+ expect(MockEventSource.instances).toHaveLength(0);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(MockEventSource.instances).toHaveLength(1);
+ expect(MockEventSource.instances[0]?.url).toBe("/api/projects/project-1/events");
+ });
+
+ it("uses the latest callback without reinitializing connection", () => {
+ const firstHandler = vi.fn<(event: PipelineEvent) => void>();
+ const secondHandler = vi.fn<(event: PipelineEvent) => void>();
+
+ const { rerender } = renderHook(
+ ({ onEvent }) => useSse("project-1", onEvent),
+ { initialProps: { onEvent: firstHandler } }
+ );
+
+ expect(MockEventSource.instances).toHaveLength(1);
+
+ rerender({ onEvent: secondHandler });
+
+ const source = MockEventSource.instances[0];
+ const event = { type: "stats", payload: { tokensUsed: 3 } } as unknown as PipelineEvent;
+
+ act(() => {
+ source?.emitMessage(event, "evt-100");
+ });
+
+ expect(firstHandler).not.toHaveBeenCalled();
+ expect(secondHandler).toHaveBeenCalledTimes(1);
+ expect(MockEventSource.instances).toHaveLength(1);
+ });
+
+ it("closes current stream and resets state when projectId changes", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ const { rerender } = renderHook(
+ ({ projectId }) => useSse(projectId, onEvent),
+ { initialProps: { projectId: "project-1" } }
+ );
+
+ const first = MockEventSource.instances[0];
+ act(() => {
+ first?.emitMessage({ id: "evt-1", type: "log", payload: {} } as PipelineEvent, "evt-1");
+ });
+
+ rerender({ projectId: "project-2" });
+
+ expect(first?.closed).toBe(true);
+ expect(MockEventSource.instances).toHaveLength(2);
+ expect(MockEventSource.instances[1]?.url).toBe("/api/projects/project-2/events");
+ });
+
+ it("closes stream on unmount and prevents future reconnects", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ const { unmount } = renderHook(() => useSse("project-1", onEvent));
+
+ const source = MockEventSource.instances[0];
+
+ act(() => {
+ source?.emitError();
+ });
+
+ unmount();
+
+ expect(source?.closed).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
+
+ expect(MockEventSource.instances).toHaveLength(1);
+ });
+});
diff --git a/src/hooks/use-sse.test.ts b/src/hooks/use-sse.test.ts
new file mode 100644
index 0000000..63a156f
--- /dev/null
+++ b/src/hooks/use-sse.test.ts
@@ -0,0 +1,291 @@
+import { render } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { PipelineEvent } from "@/lib/types/domain";
+import { useSse, type SseHookError } from "@/hooks/use-sse";
+
+class MockEventSource {
+ static instances: MockEventSource[] = [];
+
+ readonly url: string;
+ readonly withCredentials = false;
+ readonly CONNECTING = 0;
+ readonly OPEN = 1;
+ readonly CLOSED = 2;
+ readyState = 1;
+ closeCalls = 0;
+
+ onopen: ((this: EventSource, ev: Event) => unknown) | null = null;
+ onmessage: ((this: EventSource, ev: MessageEvent) => unknown) | null = null;
+ onerror: ((this: EventSource, ev: Event) => unknown) | null = null;
+
+ constructor(url: string | URL) {
+ this.url = String(url);
+ MockEventSource.instances.push(this);
+ }
+
+ close(): void {
+ this.closeCalls += 1;
+ this.readyState = this.CLOSED;
+ }
+
+ addEventListener(): void {
+ // Not needed for these tests.
+ }
+
+ removeEventListener(): void {
+ // Not needed for these tests.
+ }
+
+ dispatchEvent(): boolean {
+ return true;
+ }
+
+ emitRaw(data: string, lastEventId = ""): void {
+ this.onmessage?.call(this as unknown as EventSource, {
+ data,
+ lastEventId
+ } as MessageEvent);
+ }
+
+ emitJson(payload: unknown, lastEventId = ""): void {
+ this.emitRaw(JSON.stringify(payload), lastEventId);
+ }
+
+ emitError(): void {
+ this.onerror?.call(this as unknown as EventSource, new Event("error"));
+ }
+}
+
+interface HarnessProps {
+ projectId: string;
+ onEvent: (event: PipelineEvent) => void;
+ onError?: (error: SseHookError) => void;
+}
+
+function Harness({ projectId, onEvent, onError }: HarnessProps): null {
+ useSse(projectId, onEvent, onError);
+ return null;
+}
+
+describe("useSse", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ MockEventSource.instances = [];
+ vi.stubGlobal("EventSource", MockEventSource);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("opens an EventSource for the project events endpoint", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ render();
+
+ expect(MockEventSource.instances).toHaveLength(1);
+ expect(MockEventSource.instances[0].url).toBe("/api/projects/project-1/events");
+ });
+
+ it("forwards parsed events to onEvent", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ render();
+
+ const source = MockEventSource.instances[0];
+ const payload = {
+ id: "evt-100",
+ type: "stats",
+ payload: { durationMs: 42 }
+ } as unknown as PipelineEvent;
+
+ source.emitJson(payload);
+
+ expect(onEvent).toHaveBeenCalledTimes(1);
+ expect(onEvent).toHaveBeenCalledWith(payload);
+ });
+
+ it("uses MessageEvent.lastEventId for resume after reconnect", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ render();
+
+ const source = MockEventSource.instances[0];
+ source.emitJson({ type: "progress", payload: { pct: 50 } }, "server-id-9");
+
+ source.emitError();
+ vi.advanceTimersByTime(1000);
+
+ const reconnect = MockEventSource.instances[1];
+ expect(reconnect.url).toContain("lastEventId=server-id-9");
+ });
+
+ it("tracks fallback payload id when MessageEvent.lastEventId is absent", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ render();
+
+ const initialSource = MockEventSource.instances[0];
+ initialSource.emitJson({
+ id: "evt-101",
+ type: "stats",
+ payload: { durationMs: 42 }
+ });
+
+ initialSource.emitError();
+ vi.advanceTimersByTime(1000);
+
+ const reconnectedSource = MockEventSource.instances[1];
+ expect(reconnectedSource.url).toContain("lastEventId=evt-101");
+ });
+
+ it("extracts fallback id from root eventId and nested payload ids", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ render();
+
+ const first = MockEventSource.instances[0];
+ first.emitJson({ eventId: 123, type: "progress", payload: {} });
+ first.emitError();
+ vi.advanceTimersByTime(1000);
+
+ expect(MockEventSource.instances[1].url).toContain("lastEventId=123");
+
+ const second = MockEventSource.instances[1];
+ second.emitJson({ type: "progress", payload: { id: "nested-7" } });
+ second.emitError();
+ vi.advanceTimersByTime(2000);
+
+ expect(MockEventSource.instances[2].url).toContain("lastEventId=nested-7");
+
+ const third = MockEventSource.instances[2];
+ third.emitJson({ type: "progress", payload: { eventId: 456 } });
+ third.emitError();
+ vi.advanceTimersByTime(4000);
+
+ expect(MockEventSource.instances[3].url).toContain("lastEventId=456");
+ });
+
+ it("reports parse errors with structured metadata and raw payload", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+ const onError = vi.fn<(error: SseHookError) => void>();
+
+ render();
+
+ MockEventSource.instances[0].emitRaw("{not-json", "evt-last-1");
+
+ expect(onEvent).not.toHaveBeenCalled();
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: "parse",
+ status: 0,
+ projectId: "project-1",
+ lastEventId: "evt-last-1",
+ rawData: "{not-json"
+ })
+ );
+ });
+
+ it("reports connection errors and reconnects with exponential backoff capped at 5000ms", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+ const onError = vi.fn<(error: SseHookError) => void>();
+
+ render();
+
+ const first = MockEventSource.instances[0];
+ first.emitError();
+
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: "connection",
+ projectId: "project-1"
+ })
+ );
+
+ vi.advanceTimersByTime(999);
+ expect(MockEventSource.instances).toHaveLength(1);
+ vi.advanceTimersByTime(1);
+ expect(MockEventSource.instances).toHaveLength(2);
+
+ MockEventSource.instances[1].emitError();
+ vi.advanceTimersByTime(1999);
+ expect(MockEventSource.instances).toHaveLength(2);
+ vi.advanceTimersByTime(1);
+ expect(MockEventSource.instances).toHaveLength(3);
+
+ MockEventSource.instances[2].emitError();
+ vi.advanceTimersByTime(3999);
+ expect(MockEventSource.instances).toHaveLength(3);
+ vi.advanceTimersByTime(1);
+ expect(MockEventSource.instances).toHaveLength(4);
+
+ MockEventSource.instances[3].emitError();
+ vi.advanceTimersByTime(4999);
+ expect(MockEventSource.instances).toHaveLength(4);
+ vi.advanceTimersByTime(1);
+ expect(MockEventSource.instances).toHaveLength(5);
+
+ MockEventSource.instances[4].emitError();
+ vi.advanceTimersByTime(4999);
+ expect(MockEventSource.instances).toHaveLength(5);
+ vi.advanceTimersByTime(1);
+ expect(MockEventSource.instances).toHaveLength(6);
+ });
+
+ it("resets retry delay and lastEventId when project changes", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ const view = render();
+
+ const first = MockEventSource.instances[0];
+ first.emitJson({ id: "evt-keep", type: "progress", payload: {} });
+ first.emitError();
+ vi.advanceTimersByTime(1000);
+
+ expect(MockEventSource.instances[1].url).toContain("lastEventId=evt-keep");
+
+ view.rerender();
+
+ const project2Initial = MockEventSource.instances[2];
+ expect(project2Initial.url).toBe("/api/projects/project-2/events");
+
+ project2Initial.emitError();
+ vi.advanceTimersByTime(999);
+ expect(MockEventSource.instances).toHaveLength(3);
+ vi.advanceTimersByTime(1);
+
+ const project2Reconnect = MockEventSource.instances[3];
+ expect(project2Reconnect.url).toBe("/api/projects/project-2/events");
+ });
+
+ it("closes source and cancels pending reconnect on unmount", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+
+ const view = render();
+
+ const source = MockEventSource.instances[0];
+ source.emitError();
+ expect(source.closeCalls).toBeGreaterThan(0);
+
+ view.unmount();
+ vi.runOnlyPendingTimers();
+
+ expect(MockEventSource.instances).toHaveLength(1);
+ });
+
+ it("falls back to console.error when onError is not provided", () => {
+ const onEvent = vi.fn<(event: PipelineEvent) => void>();
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ render();
+
+ MockEventSource.instances[0].emitRaw("not-json");
+
+ expect(errorSpy).toHaveBeenCalled();
+ const firstCall = errorSpy.mock.calls[0] ?? [];
+ expect(String(firstCall[0])).toContain("[useSse]");
+ });
+});
diff --git a/src/hooks/use-sse.ts b/src/hooks/use-sse.ts
index bd1c508..a826e8a 100644
--- a/src/hooks/use-sse.ts
+++ b/src/hooks/use-sse.ts
@@ -3,36 +3,176 @@
import { useEffect, useRef } from "react";
import type { PipelineEvent } from "@/lib/types/domain";
-export function useSse(projectId: string, onEvent: (event: PipelineEvent) => void): void {
- const retryRef = useRef(1000);
+const INITIAL_RETRY_MS = 1000;
+const MAX_RETRY_MS = 5000;
+
+export interface SseHookError {
+ kind: "parse" | "connection";
+ status: number;
+ message: string;
+ projectId: string;
+ lastEventId?: string;
+ rawData?: string;
+ cause?: unknown;
+}
+
+interface EventWithOptionalId {
+ id?: string | number;
+ eventId?: string | number;
+ payload?: unknown;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function toStringId(value: unknown): string | undefined {
+ if (typeof value === "string" && value.length > 0) {
+ return value;
+ }
+
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return String(value);
+ }
+
+ return undefined;
+}
+
+function extractEventId(event: unknown): string | undefined {
+ if (!isRecord(event)) {
+ return undefined;
+ }
+
+ const root = event as EventWithOptionalId;
+ const rootId = toStringId(root.id) ?? toStringId(root.eventId);
+ if (rootId) {
+ return rootId;
+ }
+
+ if (!isRecord(root.payload)) {
+ return undefined;
+ }
+
+ return toStringId(root.payload.id) ?? toStringId(root.payload.eventId);
+}
+
+function buildEventsUrl(projectId: string, lastEventId?: string): string {
+ const url = new URL(`/api/projects/${projectId}/events`, window.location.origin);
+
+ if (lastEventId) {
+ // Replay invariant: carry forward the most recently processed event id.
+ url.searchParams.set("lastEventId", lastEventId);
+ }
+
+ return `${url.pathname}${url.search}`;
+}
+
+export function useSse(
+ projectId: string,
+ onEvent: (event: PipelineEvent) => void,
+ onError?: (error: SseHookError) => void
+): void {
+ const retryRef = useRef(INITIAL_RETRY_MS);
+ const lastEventIdRef = useRef(undefined);
+
+ useEffect((): void => {
+ retryRef.current = INITIAL_RETRY_MS;
+ lastEventIdRef.current = undefined;
+ }, [projectId]);
useEffect(() => {
let source: EventSource | null = null;
- let timeoutId: ReturnType | null = null;
+ let timeoutId: ReturnType | undefined;
+ let disposed = false;
+
+ const reportError = (error: SseHookError): void => {
+ if (onError) {
+ onError(error);
+ return;
+ }
+
+ console.error(`[useSse] ${error.message}`, {
+ projectId: error.projectId,
+ status: error.status,
+ kind: error.kind,
+ lastEventId: error.lastEventId,
+ cause: error.cause
+ });
+ };
+
+ const clearConnection = (): void => {
+ if (source) {
+ source.close();
+ source = null;
+ }
+
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ timeoutId = undefined;
+ }
+ };
const connect = (): void => {
- source = new EventSource(`/api/projects/${projectId}/events`);
+ if (disposed) {
+ return;
+ }
+
+ source = new EventSource(buildEventsUrl(projectId, lastEventIdRef.current));
+
+ source.onmessage = (message: MessageEvent): void => {
+ try {
+ const parsed = JSON.parse(message.data) as PipelineEvent;
+ const fallbackId = extractEventId(parsed);
+ const resolvedId = message.lastEventId || fallbackId;
+
+ if (resolvedId) {
+ lastEventIdRef.current = resolvedId;
+ }
- source.onmessage = (message): void => {
- const parsed = JSON.parse(message.data) as PipelineEvent;
- onEvent(parsed);
- retryRef.current = 1000;
+ onEvent(parsed);
+ retryRef.current = INITIAL_RETRY_MS;
+ } catch (cause: unknown) {
+ reportError({
+ kind: "parse",
+ status: 400,
+ message: "Failed to parse SSE payload as JSON.",
+ projectId,
+ lastEventId: lastEventIdRef.current,
+ rawData: message.data,
+ cause
+ });
+ }
};
source.onerror = (): void => {
- source?.close();
- timeoutId = setTimeout(() => {
- retryRef.current = Math.min(5000, retryRef.current * 2);
+ clearConnection();
+
+ if (disposed) {
+ return;
+ }
+
+ const retryInMs = retryRef.current;
+
+ reportError({
+ kind: "connection",
+ status: 503,
+ message: `SSE connection dropped. Retrying in ${retryInMs}ms.`,
+ projectId,
+ lastEventId: lastEventIdRef.current
+ });
+
+ timeoutId = setTimeout((): void => {
+ retryRef.current = Math.min(MAX_RETRY_MS, retryRef.current * 2);
connect();
- }, retryRef.current);
+ }, retryInMs);
};
};
connect();
return (): void => {
- source?.close();
- if (timeoutId) clearTimeout(timeoutId);
+ disposed = true;
+ clearConnection();
};
- }, [onEvent, projectId]);
+ }, [onEvent, onError, projectId]);
}
diff --git a/src/test/setup-tests.integration.test.ts b/src/test/setup-tests.integration.test.ts
new file mode 100644
index 0000000..3aadbda
--- /dev/null
+++ b/src/test/setup-tests.integration.test.ts
@@ -0,0 +1,34 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+const persistentMock = vi.fn();
+const sampleObj = {
+ read(): string {
+ return "original";
+ }
+};
+
+describe("test setup integration", () => {
+ it("provides jest-dom matchers and records mock calls", () => {
+ render(hello
);
+
+ expect(screen.getByTestId("node")).toBeInTheDocument();
+
+ persistentMock("a");
+ persistentMock("b");
+ expect(persistentMock).toHaveBeenCalledTimes(2);
+
+ vi.spyOn(sampleObj, "read").mockReturnValue("mocked");
+ expect(sampleObj.read()).toBe("mocked");
+ });
+
+ it("auto-cleans DOM and restores/clears mocks between tests", () => {
+ expect(screen.queryByTestId("node")).not.toBeInTheDocument();
+
+ // Cleared by setup-tests afterEach vi.clearAllMocks().
+ expect(persistentMock).toHaveBeenCalledTimes(0);
+
+ // Restored by setup-tests afterEach vi.restoreAllMocks().
+ expect(sampleObj.read()).toBe("original");
+ });
+});
diff --git a/src/test/setup-tests.ts b/src/test/setup-tests.ts
new file mode 100644
index 0000000..790194d
--- /dev/null
+++ b/src/test/setup-tests.ts
@@ -0,0 +1,9 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+afterEach((): void => {
+ cleanup();
+ vi.clearAllMocks();
+ vi.restoreAllMocks();
+});
diff --git a/src/test/setup.test.ts b/src/test/setup.test.ts
new file mode 100644
index 0000000..b317472
--- /dev/null
+++ b/src/test/setup.test.ts
@@ -0,0 +1,55 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+describe("test setup matchMedia polyfill", () => {
+ const original = Object.getOwnPropertyDescriptor(window, "matchMedia");
+
+ afterEach(() => {
+ vi.resetModules();
+
+ if (original) {
+ Object.defineProperty(window, "matchMedia", original);
+ return;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+ delete (window as Window & { matchMedia?: unknown }).matchMedia;
+ });
+
+ it("defines window.matchMedia when missing", async () => {
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+ delete (window as Window & { matchMedia?: unknown }).matchMedia;
+
+ await import("@/test/setup");
+
+ expect(window.matchMedia).toBeTypeOf("function");
+
+ const result = window.matchMedia("(min-width: 768px)");
+ expect(result.matches).toBe(false);
+ expect(result.media).toBe("(min-width: 768px)");
+ expect(result.addEventListener).toBeTypeOf("function");
+ expect(result.removeEventListener).toBeTypeOf("function");
+ expect(result.dispatchEvent(new Event("change"))).toBe(false);
+ });
+
+ it("does not override an existing window.matchMedia", async () => {
+ const existing = vi.fn((query: string) => ({
+ matches: true,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(() => true)
+ })) as unknown as typeof window.matchMedia;
+
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: existing
+ });
+
+ await import("@/test/setup");
+
+ expect(window.matchMedia).toBe(existing);
+ });
+});
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..cee1533
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,25 @@
+import "@testing-library/jest-dom/vitest";
+
+if (typeof window !== "undefined" && !window.matchMedia) {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: (query: string): MediaQueryList => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: (): void => {
+ // Deprecated API intentionally no-op for compatibility.
+ },
+ removeListener: (): void => {
+ // Deprecated API intentionally no-op for compatibility.
+ },
+ addEventListener: (): void => {
+ // No-op for test environment.
+ },
+ removeEventListener: (): void => {
+ // No-op for test environment.
+ },
+ dispatchEvent: (): boolean => false
+ })
+ });
+}
diff --git a/src/test/tailwind-config.test.ts b/src/test/tailwind-config.test.ts
new file mode 100644
index 0000000..dfd3382
--- /dev/null
+++ b/src/test/tailwind-config.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import config from "../../tailwind.config";
+
+describe("tailwind.config", () => {
+ it("uses class-based dark mode and expected content globs", () => {
+ expect(config.darkMode).toEqual(["class"]);
+ expect(config.content).toEqual(
+ expect.arrayContaining([
+ "./src/app/**/*.{ts,tsx}",
+ "./src/components/**/*.{ts,tsx}",
+ "./src/hooks/**/*.{ts,tsx}",
+ "./src/lib/**/*.{ts,tsx}"
+ ])
+ );
+ });
+
+ it("defines theme tokens used by app styles", () => {
+ const extend = config.theme?.extend as Record;
+
+ expect(extend.colors.primary.DEFAULT).toBe("hsl(var(--primary))");
+ expect(extend.colors.primary.foreground).toBe("hsl(var(--primary-foreground))");
+ expect(extend.colors.chart["1"]).toBe("hsl(var(--chart-1))");
+ expect(extend.borderRadius.lg).toBe("0.5rem");
+ expect(extend.fontFamily.sans).toEqual(
+ expect.arrayContaining(["var(--font-inter)", "Inter", "ui-sans-serif"])
+ );
+ });
+
+ it("registers animation plugin", () => {
+ expect(config.plugins).toBeDefined();
+ expect(Array.isArray(config.plugins)).toBe(true);
+ expect((config.plugins as unknown[]).length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/test/vitest-config.test.ts b/src/test/vitest-config.test.ts
new file mode 100644
index 0000000..3d5b373
--- /dev/null
+++ b/src/test/vitest-config.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import config from "../../vitest.config";
+
+describe("vitest.config", () => {
+ it("defines src alias and jsdom test environment", () => {
+ expect(config.resolve?.alias).toBeDefined();
+ const alias = config.resolve?.alias as Record;
+ expect(alias["@"]).toMatch(/\/src$/);
+
+ expect(config.test?.environment).toBe("jsdom");
+ expect(config.test?.globals).toBe(true);
+ });
+
+ it("includes expected test patterns and setup file", () => {
+ expect(config.test?.include).toContain("src/**/*.{test,spec}.{ts,tsx}");
+ expect(config.test?.setupFiles).toContain("./src/test/setup.ts");
+ });
+
+ it("enables v8 coverage with expected reporters and paths", () => {
+ expect(config.test?.coverage?.provider).toBe("v8");
+ expect(config.test?.coverage?.reporter).toEqual(["text", "html"]);
+ expect(config.test?.coverage?.include).toContain("src/**/*.{ts,tsx}");
+ expect(config.test?.coverage?.exclude).toContain("src/**/*.d.ts");
+ expect(config.test?.coverage?.exclude).toContain("src/test/**");
+ });
+});
diff --git a/tailwind.config.ts b/tailwind.config.ts
index a8c5373..5b0c4fe 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,30 +1,69 @@
import type { Config } from "tailwindcss";
+import tailwindcssAnimate from "tailwindcss-animate";
const config: Config = {
darkMode: ["class"],
- content: ["./src/**/*.{ts,tsx}"],
+ content: [
+ "./src/app/**/*.{ts,tsx}",
+ "./src/components/**/*.{ts,tsx}",
+ "./src/hooks/**/*.{ts,tsx}",
+ "./src/lib/**/*.{ts,tsx}"
+ ],
theme: {
extend: {
colors: {
- primary: "#3B82F6",
- secondary: "#14B8A6",
- background: "#0B1020",
- foreground: "#E5E7EB",
- muted: "#1F2937",
- accent: "#8B5CF6",
- destructive: "#EF4444"
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))"
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))"
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))"
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))"
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))"
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))"
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))"
+ },
+ chart: {
+ "1": "hsl(var(--chart-1))",
+ "2": "hsl(var(--chart-2))",
+ "3": "hsl(var(--chart-3))",
+ "4": "hsl(var(--chart-4))",
+ "5": "hsl(var(--chart-5))"
+ }
},
borderRadius: {
- lg: "10px",
- xl: "12px",
- "2xl": "16px"
+ lg: "0.5rem",
+ md: "0.375rem",
+ xl: "0.75rem"
},
fontFamily: {
- sans: ["Manrope", "ui-sans-serif", "system-ui", "sans-serif"]
+ sans: ["var(--font-inter)", "Inter", "ui-sans-serif", "system-ui", "sans-serif"]
}
}
},
- plugins: []
+ plugins: [tailwindcssAnimate]
};
export default config;
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..b029f7d
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,22 @@
+import path from "node:path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src")
+ }
+ },
+ test: {
+ globals: true,
+ environment: "jsdom",
+ setupFiles: ["./src/test/setup.ts"],
+ include: ["src/**/*.{test,spec}.{ts,tsx}"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "html"],
+ include: ["src/**/*.{ts,tsx}"],
+ exclude: ["src/**/*.d.ts", "src/test/**"]
+ }
+ }
+});