Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/desktop/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"node": ">=20.19 <21 || >=22.12"
},
"dependencies": {
"@fontsource-variable/fraunces": "^5.2.9",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-query": "^5",
"@tanstack/react-router": "^1",
"@tanstack/react-virtual": "^3.13.24",
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/App.css

This file was deleted.

34 changes: 20 additions & 14 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
/**
* Root-route component. Mounts inside <RouterProvider> via router.tsx.
* Root-route shell. Mounts inside <RouterProvider> via router.tsx, which
* wraps us as `<App><Outlet /></App>` — i.e. the matched child route is
* passed via `children`. Preserve this contract; do NOT switch to
* importing <Outlet /> here.
*
* WHY this is "App" not "RootRoute": git-history continuity. The file
* is "the root component" before and after Batch H; tests + imports
* preserve their existing paths. Naming clarification: post-Batch-H,
* "App" = root-route component, NOT application root. Application
* root = main.tsx + RouterProvider + QueryClientProvider.
* Owns: layout chrome (header / main slot / footer) + useDomainEvents()
* mount + NotificationStack mount + ThemeToggle slot.
*
* Owns: layout chrome (header/footer) + useDomainEvents() mount.
* Does NOT own: file/tag/search composition (lives in routes/index.tsx)
* or any local useState/useEffect — server state via TanStack Query,
* UI state via useUiStore.
* Does NOT own: ThemeProvider (mounted in main.tsx, wrapping us);
* file/tag/search composition (lives in routes/index.tsx).
*
* WHY direct utilities (font-display text-2xl font-semibold tracking-tight)
* for the wordmark instead of .h1 / .display-xl: the wordmark is small
* (~1.5rem), not hero-sized. The semantic typography classes bake in
* size + opsz + line-height for editorial display use; mixing them with
* utility-overrides relies on Tailwind layer order and is fragile.
*/
import type { ReactNode } from "react";
import { useDomainEvents } from "./hooks/useDomainEvents";
Expand All @@ -19,21 +23,23 @@ import ScanButton from "./components/ScanButton";
import StatusBar from "./components/StatusBar";
import ViewModeToggle from "./components/ViewModeToggle";
import NotificationStack from "./components/NotificationStack";
import ThemeToggle from "./components/ThemeToggle";

export default function App({ children }: { children?: ReactNode }) {
useDomainEvents();
return (
<div className="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
<header className="flex items-center justify-between px-6 py-4 bg-gray-800 border-b border-gray-700">
<h1 className="text-xl font-bold tracking-wide">perima</h1>
<div className="min-h-screen flex flex-col bg-background text-foreground font-sans">
<header className="flex items-center justify-between px-6 py-4 bg-card border-b border-border">
<h1 className="font-display text-2xl font-semibold tracking-tight">perima</h1>
<div className="flex items-center gap-3">
<SearchBar />
<ViewModeToggle />
<ScanButton />
<ThemeToggle />
</div>
</header>
<NotificationStack />
{children}
<main className="flex-1 min-h-0">{children}</main>
<footer>
<StatusBar />
</footer>
Expand Down
50 changes: 25 additions & 25 deletions apps/desktop/src/__tests__/CollisionPill.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* CollisionPill — color-state pill per spec §4.6.1.
* CollisionPill — design-token pill per spec §4.6.1.
*
* Branches under test:
* - 0 groups: gray, "no candidate duplicates"
* - N groups, 0 verified: blue, "N candidate group(s)"
* - 1 group, 0 verified: blue, "1 candidate group" (singular)
* - N groups, 0 less than M less than N verified: blue, "N candidate (M )"
* - all verified: green, "all verified"
* - 0 groups: muted-foreground span, "no candidate duplicates"
* - N groups, 0 verified: warning pill, "N duplicate(s)"
* - 1 group, 0 verified: warning pill, "1 duplicate" (singular)
* - N groups, 0 less than M less than N verified: warning pill, "N duplicates (M verified)"
* - all verified: success pill, "all verified"
*
* WHY mock `@tanstack/react-router`: CollisionPill renders a Link to="/dedup"
* which requires a router context. Unit tests for a leaf pill component do not
Expand Down Expand Up @@ -55,16 +55,16 @@ function makeGroup(
// ── Tests ─────────────────────────────────────────────────────────────────────

describe("CollisionPill", () => {
it("renders gray 'no candidate duplicates' when groups is empty", () => {
it("renders muted 'no candidate duplicates' span when groups is empty", () => {
renderWithProviders(<CollisionPill groups={[]} />);
const el = screen.getByText(/no candidate duplicates/i);
expect(el).toBeInTheDocument();
// WHY check class presence: gray state = text-gray-500, not a link.
// 0-groups state = plain span (not a link), text-muted-foreground.
expect(el.tagName).toBe("SPAN");
expect(el.className).toContain("text-gray-500");
expect(el.className).toContain("text-muted-foreground");
});

it("renders blue pill for N unverified groups (plural)", () => {
it("renders warning pill for N unverified groups (plural)", () => {
const groups = [
makeGroup("aaa", "Unverified"),
makeGroup("bbb", "Unverified"),
Expand All @@ -73,20 +73,20 @@ describe("CollisionPill", () => {
renderWithProviders(<CollisionPill groups={groups} />);
const link = screen.getByRole("link");
expect(link).toBeInTheDocument();
expect(link.textContent).toMatch(/3 candidate groups/i);
expect(link.className).toContain("text-blue-400");
expect(link.textContent).toMatch(/3 duplicates/i);
expect(link.className).toContain("bg-warning");
});

it("renders blue pill with singular 'group' label for 1 unverified group", () => {
it("renders warning pill with singular 'duplicate' label for 1 unverified group", () => {
renderWithProviders(<CollisionPill groups={[makeGroup("aaa", "Unverified")]} />);
const link = screen.getByRole("link");
expect(link.textContent).toMatch(/1 candidate group$/i);
expect(link.textContent).toMatch(/1 duplicate$/i);
// Must NOT be plural.
expect(link.textContent).not.toMatch(/1 candidate groups/i);
expect(link.className).toContain("text-blue-400");
expect(link.textContent).not.toMatch(/1 duplicates/i);
expect(link.className).toContain("bg-warning");
});

it("renders blue pill with verified count when 0 < M < N verified", () => {
it("renders warning pill with verified count when 0 < M < N verified", () => {
const groups = [
makeGroup("aaa", "VerifiedDuplicate"),
makeGroup("bbb", "Unverified"),
Expand All @@ -95,19 +95,19 @@ describe("CollisionPill", () => {
renderWithProviders(<CollisionPill groups={groups} />);
const link = screen.getByRole("link");
// 3 total, 2 verified (VerifiedDuplicate + VerifiedDistinct both count).
expect(link.textContent).toMatch(/3 candidates \(2 \)/i);
expect(link.className).toContain("text-blue-400");
expect(link.textContent).toMatch(/3 duplicates \(2 verified\)/i);
expect(link.className).toContain("bg-warning");
});

it("renders green pill when all groups are verified", () => {
it("renders success pill when all groups are verified", () => {
const groups = [
makeGroup("aaa", "VerifiedDuplicate"),
makeGroup("bbb", "VerifiedDistinct"),
];
renderWithProviders(<CollisionPill groups={groups} />);
const link = screen.getByRole("link");
expect(link.textContent).toMatch(/all verified/i);
expect(link.className).toContain("text-green-400");
expect(link.textContent).toMatch(/all verified/i);
expect(link.className).toContain("bg-success");
});

it("link points to /dedup", () => {
Expand All @@ -121,8 +121,8 @@ describe("CollisionPill", () => {
renderWithProviders(<CollisionPill groups={groups} />);
const link = screen.getByRole("link");
// Mixed is not VerifiedDuplicate or VerifiedDistinct → not in verified count.
// With 0 verified, label = "1 candidate group".
expect(link.textContent).toMatch(/1 candidate group$/i);
expect(link.className).toContain("text-blue-400");
// With 0 verified, label = "1 duplicate".
expect(link.textContent).toMatch(/1 duplicate$/i);
expect(link.className).toContain("bg-warning");
});
});
22 changes: 4 additions & 18 deletions apps/desktop/src/__tests__/TagChip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,6 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import TagChip from "../components/TagChip";

// colorIndexFor is not exported, so we test it indirectly via the rendered chip.
function colorIndexFor(name: string): number {
const bytes = new TextEncoder().encode(name);
let sum = 0;
for (const b of bytes) sum = (sum + b) % 256;
return sum % 12;
}

describe("TagChip", () => {
const sampleTag = {
id: "00000000-0000-0000-0000-000000000001",
Expand All @@ -35,17 +27,11 @@ describe("TagChip", () => {
expect(onRemove).toHaveBeenCalledTimes(1);
});

test("chip has a color class derived from tag name", () => {
test("chip uses uniform bg-muted class (design-system-v1 drops per-tag coloring)", () => {
render(<TagChip tag={sampleTag} />);
const chip = screen.getByTestId("tag-chip");
// colorIndexFor("vacation") must be stable — pin the expected index so
// an accidental formula change is caught immediately.
const idx = colorIndexFor("vacation");
const COLORS = [
"bg-red-700","bg-orange-700","bg-amber-700","bg-yellow-700",
"bg-lime-700","bg-green-700","bg-emerald-700","bg-teal-700",
"bg-cyan-700","bg-sky-700","bg-blue-700","bg-indigo-700",
];
expect(chip.className).toContain(COLORS[idx]);
// WHY: design-system-v1 replaces the 12-color procedural palette with a
// single uniform bg-muted capsule. Tag identity is conveyed by name text.
expect(chip.className).toContain("bg-muted");
});
});
51 changes: 51 additions & 0 deletions apps/desktop/src/__tests__/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, test, expect, beforeEach, vi } from "vitest";
import { render, fireEvent } from "@testing-library/react";
import { ThemeProvider } from "../lib/theme-provider";
import ThemeToggle from "../components/ThemeToggle";

describe("ThemeToggle", () => {
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});

test("renders Moon icon initially (dark mode default for tests)", () => {
localStorage.clear();
localStorage.setItem("perima-theme", "dark");
const { container } = render(
<ThemeProvider><ThemeToggle /></ThemeProvider>,
);
const btn = container.querySelector("button");
expect(btn).not.toBeNull();
expect(btn!.getAttribute("aria-label")).toMatch(/dark/i);
});

test("clicking cycles dark -> light -> system -> dark", () => {
localStorage.clear();
localStorage.setItem("perima-theme", "dark");
const { container } = render(
<ThemeProvider><ThemeToggle /></ThemeProvider>,
);
const btn = container.querySelector("button")!;

fireEvent.click(btn);
expect(btn.getAttribute("aria-label")).toMatch(/light/i);

fireEvent.click(btn);
expect(btn.getAttribute("aria-label")).toMatch(/system/i);

fireEvent.click(btn);
expect(btn.getAttribute("aria-label")).toMatch(/dark/i);
});
});
38 changes: 37 additions & 1 deletion apps/desktop/src/__tests__/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,36 @@
import type { ReactElement } from "react";
import { render, type RenderResult } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { vi } from "vitest";
import { useUiStore, type UiStore } from "../stores/ui";
import { ThemeProvider } from "../lib/theme-provider";

// WHY install matchMedia mock at module-load time: jsdom does not implement
// window.matchMedia. ThemeProvider's getSystemTheme() reads it at mount, and
// renderWithProviders wraps in ThemeProvider — so every test using the helper
// would crash without this mock. Installing it here (vs in each test's
// beforeEach) is a one-shot fix and avoids per-test boilerplate. Tests that
// need to override matchMedia (e.g. simulate prefers-color-scheme: light)
// can re-define it in their own beforeEach — last assignment wins.
// WHY cast to unknown: TypeScript types window.matchMedia as always-defined
// (lib.dom.d.ts), but jsdom doesn't implement it. The `as unknown` cast lets
// the runtime truthiness check bypass the TS type system so the guard works.
if (typeof window !== "undefined" && !(window as unknown as Record<string, unknown>)["matchMedia"]) {
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}

/**
* Default UI-store state matching slice initial values.
Expand Down Expand Up @@ -79,8 +108,15 @@ export function renderWithProviders(
if (opts.initialStoreState) {
useUiStore.setState(opts.initialStoreState);
}
// WHY ThemeProvider wrap: any tested component that calls useTheme()
// needs a provider. Defaults to "system" mode → "dark" effective theme
// in jsdom (matchMedia mock returns matches: false). Tests that need a
// specific theme should set localStorage["perima-theme"] in beforeEach
// (e.g. ThemeToggle.test.tsx).
const result = render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
<ThemeProvider>
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
</ThemeProvider>,
);
return Object.assign(result, { queryClient });
}
Loading
Loading