diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index 8265a04..63b8c96 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -5,6 +5,10 @@ "": { "name": "perima-desktop", "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", @@ -196,6 +200,12 @@ "@evilmartians/lefthook": ["@evilmartians/lefthook@2.1.6", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "ia32", "arm64", ], "bin": { "lefthook": "bin/index.js" } }, "sha512-ysZbzryf74wlISmgm0PH/n1lJ0HD7AHmI6DoJgWO9qzIETQknhGdmKbkOi0MjLYtiOHWQl8Dr2qwg0ksDpNZjA=="], + "@fontsource-variable/fraunces": ["@fontsource-variable/fraunces@5.2.9", "", {}, "sha512-Y6IjunlN9Ni723np+GIgAaKzCDBrPRrqNi01TZxHs5wtHYROWFM9W6yiT+/gGwSjWIRD18oX17kD/BRWekc/Lw=="], + + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -228,6 +238,8 @@ "@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="], + "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8c429f2..2416752 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css deleted file mode 100644 index 421167d..0000000 --- a/apps/desktop/src/App.css +++ /dev/null @@ -1,2 +0,0 @@ -/* WHY: Tailwind 4 uses @import instead of @tailwind directives from v3. */ -@import "tailwindcss"; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9b44b68..e67ce1d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,16 +1,20 @@ /** - * Root-route component. Mounts inside via router.tsx. + * Root-route shell. Mounts inside via router.tsx, which + * wraps us as `` — i.e. the matched child route is + * passed via `children`. Preserve this contract; do NOT switch to + * importing 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"; @@ -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 ( -
-
-

perima

+
+
+

perima

+
- {children} +
{children}
diff --git a/apps/desktop/src/__tests__/CollisionPill.test.tsx b/apps/desktop/src/__tests__/CollisionPill.test.tsx index 3df251e..3b060b1 100644 --- a/apps/desktop/src/__tests__/CollisionPill.test.tsx +++ b/apps/desktop/src/__tests__/CollisionPill.test.tsx @@ -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 @@ -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(); 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"), @@ -73,20 +73,20 @@ describe("CollisionPill", () => { renderWithProviders(); 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(); 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"), @@ -95,19 +95,19 @@ describe("CollisionPill", () => { renderWithProviders(); 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(); 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", () => { @@ -121,8 +121,8 @@ describe("CollisionPill", () => { renderWithProviders(); 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"); }); }); diff --git a/apps/desktop/src/__tests__/TagChip.test.tsx b/apps/desktop/src/__tests__/TagChip.test.tsx index db64ca7..295ec06 100644 --- a/apps/desktop/src/__tests__/TagChip.test.tsx +++ b/apps/desktop/src/__tests__/TagChip.test.tsx @@ -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", @@ -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(); 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"); }); }); diff --git a/apps/desktop/src/__tests__/ThemeToggle.test.tsx b/apps/desktop/src/__tests__/ThemeToggle.test.tsx new file mode 100644 index 0000000..1f576c3 --- /dev/null +++ b/apps/desktop/src/__tests__/ThemeToggle.test.tsx @@ -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( + , + ); + 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( + , + ); + 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); + }); +}); diff --git a/apps/desktop/src/__tests__/test-utils.tsx b/apps/desktop/src/__tests__/test-utils.tsx index 6ca68a2..6e5aac5 100644 --- a/apps/desktop/src/__tests__/test-utils.tsx +++ b/apps/desktop/src/__tests__/test-utils.tsx @@ -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)["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. @@ -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( - {ui}, + + {ui} + , ); return Object.assign(result, { queryClient }); } diff --git a/apps/desktop/src/__tests__/theme-provider.test.tsx b/apps/desktop/src/__tests__/theme-provider.test.tsx new file mode 100644 index 0000000..5ef60da --- /dev/null +++ b/apps/desktop/src/__tests__/theme-provider.test.tsx @@ -0,0 +1,93 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, act } from "@testing-library/react"; +import { ThemeProvider, useTheme } from "../lib/theme-provider"; + +const ThemeProbe = () => { + const { theme, effectiveTheme, setTheme } = useTheme(); + return ( +
+ {theme} + {effectiveTheme} + + + +
+ ); +}; + +describe("ThemeProvider", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("data-theme"); + // WHY matchMedia mock: jsdom does not implement window.matchMedia by + // default; ThemeProvider's useEffect calls it for system-pref detection. + // Without this mock, every test would throw "matchMedia is not a function". + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, // default to NOT-light → effectiveTheme = "dark" in system mode + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), // legacy + removeListener: vi.fn(), // legacy + dispatchEvent: vi.fn(), + })), + }); + }); + + afterEach(() => { + document.documentElement.removeAttribute("data-theme"); + }); + + test("defaults to system mode when no localStorage value", () => { + const { getByTestId } = render( + , + ); + expect(getByTestId("theme").textContent).toBe("system"); + }); + + test("setTheme('light') sets data-theme=light on ", () => { + const { getByText } = render( + , + ); + act(() => { getByText("set-light").click(); }); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + }); + + test("setTheme('dark') removes data-theme attr", () => { + const { getByText } = render( + , + ); + act(() => { getByText("set-light").click(); }); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + act(() => { getByText("set-dark").click(); }); + expect(document.documentElement.getAttribute("data-theme")).toBe(null); + }); + + test("setTheme persists to localStorage under perima-theme key", () => { + const { getByText } = render( + , + ); + act(() => { getByText("set-light").click(); }); + expect(localStorage.getItem("perima-theme")).toBe("light"); + }); + + test("reads stored theme on mount", () => { + localStorage.setItem("perima-theme", "light"); + const { getByTestId } = render( + , + ); + expect(getByTestId("theme").textContent).toBe("light"); + expect(getByTestId("effective").textContent).toBe("light"); + }); + + test("useTheme outside provider throws", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + /useTheme must be used within /, + ); + spy.mockRestore(); + }); +}); diff --git a/apps/desktop/src/components/CollisionPill.tsx b/apps/desktop/src/components/CollisionPill.tsx index 62d4f47..86d8824 100644 --- a/apps/desktop/src/components/CollisionPill.tsx +++ b/apps/desktop/src/components/CollisionPill.tsx @@ -1,25 +1,19 @@ /** * Collision-group status pill for the StatusBar. * - * Color states per spec §4.6.1: - * - 0 groups: gray, "no candidate duplicates" - * - N groups, 0 verified: blue, "N candidate group(s)" - * - N groups, 0 less than M less than N verified: blue, "N candidate (M ✓)" - * - all verified: green, "all verified ✓" - * - errored greater than 0: yellow, "verify error" (reserved for future batch) + * WHY warning/success encoding: unverified groups are actionable (they need + * a human or batch verify); verified groups are informational. Color encodes + * urgency, not just count. Warning = unverified present, success = all clear. * - * WHY gray-by-default for 0 groups: collisions are expected to be - * absent in a well-managed library; green "all clear" would be noisy - * and draws attention away from actionable states. - * - * WHY Link to="/dedup": clicking navigates to the dedup management - * route (Task 13). The route is registered in router.tsx; TS validates - * the `to` prop via TanStack Router's `Register` augmentation. + * WHY Link to="/dedup": clicking navigates to the dedup management route. + * The route is registered in router.tsx; TS validates the `to` prop via + * TanStack Router's `Register` augmentation. * * WHY VerifiedState is a plain string union (not discriminated object): * Rust emits unit enum variants as bare strings under the default serde * config (no `#[serde(tag)]`). Pattern-match via ===, not `.kind`. */ +import { CopyIcon } from "@phosphor-icons/react"; import { Link } from "@tanstack/react-router"; import type { CollisionGroup } from "../bindings"; @@ -36,8 +30,8 @@ export default function CollisionPill({ groups }: Props) { if (total === 0) { return ( - - ⊜ no candidate duplicates + + no candidate duplicates ); } @@ -51,24 +45,31 @@ export default function CollisionPill({ groups }: Props) { g.verified_state === "VerifiedDistinct", ).length; - let colorClass = "text-blue-400"; let label: string; - if (verified === total) { - colorClass = "text-green-400"; - label = "⊜ all verified ✓"; + label = "all verified"; } else if (verified > 0) { - label = `⊜ ${total} candidate${total === 1 ? "" : "s"} (${verified} ✓)`; + label = `${total} duplicate${total === 1 ? "" : "s"} (${verified} verified)`; } else { - label = `⊜ ${total} candidate group${total === 1 ? "" : "s"}`; + label = `${total} duplicate${total === 1 ? "" : "s"}`; } + // WHY bg-warning when there are unverified groups, bg-success when all verified: + // unverified groups are actionable (they need a human or batch verify); verified + // groups are informational. Color encodes urgency, not just count. + const bgClass = + verified === total ? "bg-success text-success-foreground" : "bg-warning text-warning-foreground"; + return ( + {label} ); diff --git a/apps/desktop/src/components/FileGrid.tsx b/apps/desktop/src/components/FileGrid.tsx index 0ff24e4..0c14157 100644 --- a/apps/desktop/src/components/FileGrid.tsx +++ b/apps/desktop/src/components/FileGrid.tsx @@ -24,19 +24,19 @@ interface FileGridProps { export default function FileGrid({ files, loading = false }: FileGridProps) { if (loading) { return ( -
+
Loading...
); } if (files.length === 0) { return ( -
No files indexed yet
+
No files indexed yet
); } return (
{files.map((f) => ( @@ -55,10 +55,10 @@ function FileGridTile({ file }: { file: FileWithTagsPayload }) { const filename = file.relative_path.split("/").pop() ?? file.relative_path; return (
-
+
{ready ? ( // WHY convertFileSrc: Tauri's asset protocol maps absolute // disk paths to a custom URL scheme the WebView can load @@ -72,16 +72,16 @@ function FileGridTile({ file }: { file: FileWithTagsPayload }) { )}
-
+
{filename}
{file.tags.length > 0 && ( -
+
{file.tags.slice(0, 3).map((t) => ( ))} {file.tags.length > 3 && ( - + +{file.tags.length - 3} )} @@ -109,7 +109,7 @@ function PlaceholderIcon({ status }: { status: string | null | undefined }) { : "no thumbnail"; return (
{/* Header */}
-

File detail

+

File detail

{/* UUID */}
-

+

UUID

{file.file_uuid.slice(0, 8)}… @@ -93,11 +94,11 @@ export default function FileSidebar({ file, onClose }: FileSidebarProps) { {/* Quick hash */} {file.quick_hash !== null && (
-

+

Quick hash

{file.quick_hash} @@ -107,14 +108,14 @@ export default function FileSidebar({ file, onClose }: FileSidebarProps) { {/* Full hash */}
-

+

Full hash

{!isPlaceholder && file.hash !== null ? ( // WHY click-to-copy on the element: spec §4.6.3 UX win, // ≤10 LOC so we land it inline rather than deferring. ) : ( - + pending )} @@ -135,7 +136,7 @@ export default function FileSidebar({ file, onClose }: FileSidebarProps) { disabled={compute.isPending} aria-label="Compute canonical hash" data-testid="compute-hash-btn" - className="mt-auto bg-blue-600 hover:bg-blue-500 disabled:bg-gray-600 text-white text-xs font-medium rounded px-3 py-2 transition-colors" + className="mt-auto inline-flex items-center justify-center gap-2 rounded-full px-5 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors duration-micro ease-perima focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-40 disabled:pointer-events-none" > {compute.isPending ? "Computing…" : "Compute canonical hash"} diff --git a/apps/desktop/src/components/FileTable.tsx b/apps/desktop/src/components/FileTable.tsx index 34efe46..819b0d7 100644 --- a/apps/desktop/src/components/FileTable.tsx +++ b/apps/desktop/src/components/FileTable.tsx @@ -81,7 +81,7 @@ function RowTagsCell({ placeholder="+ tag" aria-label={`Add tag to file ${labelKey.slice(0, 8)}`} disabled={isPending} - className="w-20 bg-gray-700 text-white text-xs rounded px-1.5 py-0.5 placeholder:text-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + className="w-20 bg-input text-foreground text-xs rounded-md border border-border px-2 py-0.5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
); @@ -148,7 +148,7 @@ export default function FileTable({ files, loading }: FileTableProps) { }); const thCls = - "px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-400 cursor-pointer select-none hover:text-gray-100"; + "px-4 py-2 text-left eyebrow text-muted-foreground cursor-pointer select-none hover:text-foreground"; function arrow(col: SortColumn) { if (sortBy !== col) return null; @@ -157,8 +157,8 @@ export default function FileTable({ files, loading }: FileTableProps) { return (
- - +
+ {loading ? ( - ) : sorted.length === 0 ? ( - @@ -208,14 +208,14 @@ export default function FileTable({ files, loading }: FileTableProps) { selectedFileUuid === f.file_uuid ? null : f.file_uuid, ); }} - className={`border-t border-gray-700 cursor-pointer ${ + className={`border-b border-border cursor-pointer transition-colors duration-micro ${ selectedFileUuid === f.file_uuid - ? "bg-blue-900" - : "odd:bg-gray-900 even:bg-gray-800 hover:bg-gray-700" + ? "bg-accent text-accent-foreground" + : "hover:bg-muted" }`} aria-selected={selectedFileUuid === f.file_uuid} > - - - + - - - +
{ handleSort("hash"); }}> HASH{arrow("hash")} @@ -181,13 +181,13 @@ export default function FileTable({ files, loading }: FileTableProps) {
+ Loading...
+ No files indexed yet
+ {/* WHY isPlaceholder check: pre-V012 blake3_hash is NOT NULL — * quick_hash is stored there until compute_full_hash promotes * the real hash. f.hash === null never fires today; equality @@ -224,15 +224,15 @@ export default function FileTable({ files, loading }: FileTableProps) { ? "pending" : f.hash.slice(0, 8)} {humanSize(f.size)} + {humanSize(f.size)} {f.volume_id.slice(0, 8)} + {f.relative_path} {f.status} + {f.status} { clearTimeout(timer); }; }, [id, kind, dismiss]); - const bg = kind === "error" ? "bg-red-700" : "bg-blue-700"; + const variantClasses = + kind === "error" + ? "border-destructive bg-destructive/10 text-destructive-foreground" + : "border-info bg-popover text-popover-foreground"; return (
- {message} + {message} ); diff --git a/apps/desktop/src/components/SearchBar.tsx b/apps/desktop/src/components/SearchBar.tsx index b8d91f9..a738b15 100644 --- a/apps/desktop/src/components/SearchBar.tsx +++ b/apps/desktop/src/components/SearchBar.tsx @@ -10,6 +10,7 @@ * sanitiser (escape FTS5 metacharacters), clearedRef deduplication. */ import { useEffect, useRef } from "react"; +import { MagnifyingGlassIcon } from "@phosphor-icons/react"; import { useUiStore } from "../stores/ui"; import { MIN_QUERY_LEN } from "../queries/search"; import { buildFtsQuery } from "../lib/search"; @@ -45,13 +46,24 @@ export default function SearchBar() { }, [searchQuery, setDebouncedQuery]); return ( - { setSearchQuery(e.target.value); }} - className="px-3 py-1.5 bg-gray-900 text-gray-100 rounded border border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm w-64" - /> +
+ + { setSearchQuery(e.target.value); }} + className="rounded-full bg-input text-foreground placeholder:text-muted-foreground + pl-9 pr-4 py-2 text-sm border border-border w-64 + focus-visible:outline-none focus-visible:ring-2 + focus-visible:ring-ring focus-visible:ring-offset-0 + focus-visible:border-ring transition-colors duration-micro" + /> +
); } diff --git a/apps/desktop/src/components/StatusBar.tsx b/apps/desktop/src/components/StatusBar.tsx index 8537ad3..7e52d5e 100644 --- a/apps/desktop/src/components/StatusBar.tsx +++ b/apps/desktop/src/components/StatusBar.tsx @@ -123,7 +123,7 @@ export default function StatusBar() { } return ( -
+
{summary}
{/* WHY type="button": prevents form submission if StatusBar is ever @@ -132,7 +132,11 @@ export default function StatusBar() { type="button" onClick={() => { backupMutation.mutate({}); }} disabled={backupMutation.isPending} - className="px-2 py-0.5 text-xs rounded bg-slate-700 text-slate-100 hover:bg-slate-600 disabled:opacity-50" + className="rounded-full px-3 py-1 text-sm font-medium bg-secondary text-secondary-foreground + hover:bg-popover transition-colors duration-micro ease-perima + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring + focus-visible:ring-offset-2 focus-visible:ring-offset-background + disabled:opacity-40 disabled:pointer-events-none" > {backupMutation.isPending ? "Backing up…" : "Backup"} diff --git a/apps/desktop/src/components/TagChip.tsx b/apps/desktop/src/components/TagChip.tsx index 0f155a1..22187c1 100644 --- a/apps/desktop/src/components/TagChip.tsx +++ b/apps/desktop/src/components/TagChip.tsx @@ -1,55 +1,23 @@ +import { XIcon } from "@phosphor-icons/react"; import type { Tag } from "../bindings"; /** Props for {@link TagChip}. */ interface TagChipProps { /** Tag to render. */ tag: Tag; - /** Optional callback to remove the tag; when provided, renders an x button. */ + /** Optional callback to remove the tag; when provided, renders an X button. */ onRemove?: () => void; } /** - * Compute a procedural color index (0..11) from a tag name. - * - * WHY byte-sum mod 12 (not spec's blake3[0] % 12): blake3 isn't available - * in the TS bundle without a WASM dependency. The byte-sum is a deliberate - * intentional deviation from the spec for cosmetic coloring only — collisions - * (two tags sharing a chip color) are harmless. If a native/backend shell - * ever needs to agree on chip color, revisit with a shared WASM blake3. - */ -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; -} - -const CHIP_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", -] as const; - -/** - * A colored pill displaying a tag name, with optional remove button. + * A muted-background pill displaying a tag name, with an optional X icon + * for removal. Tag identity is conveyed by the name text — chip color is + * uniform across all tags by design (design-system-v1). */ export default function TagChip({ tag, onRemove }: TagChipProps) { - // WHY fallback to bg-blue-700: colorIndexFor is bounded to 0..11 which - // matches CHIP_COLORS.length exactly; the fallback satisfies strict-type-checked - // (noUncheckedIndexedAccess sees the index as `number`, not a literal). - const bg = CHIP_COLORS[colorIndexFor(tag.name)] ?? "bg-blue-700"; return ( {tag.name} @@ -58,9 +26,9 @@ export default function TagChip({ tag, onRemove }: TagChipProps) { type="button" onClick={onRemove} aria-label={`Remove ${tag.name}`} - className="ml-0.5 text-white/80 hover:text-white" + className="inline-flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors duration-micro ease-perima focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > - × + )} diff --git a/apps/desktop/src/components/TagSidebar.tsx b/apps/desktop/src/components/TagSidebar.tsx index 1461f5d..04df69d 100644 --- a/apps/desktop/src/components/TagSidebar.tsx +++ b/apps/desktop/src/components/TagSidebar.tsx @@ -43,7 +43,7 @@ export default function TagSidebar({ return (