-
+
+
{logs.length === 0 ? (
- No logs yet. Click sync to start.
+ No logs yet. Click sync to start.
) : (
logs.map((line, i) => (
void;
+}
+
+const ThemeContext = createContext
(null);
+
+function readStoredTheme(): Theme {
+ if (typeof window === "undefined") return "system";
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (stored === "light" || stored === "dark" || stored === "system") return stored;
+ return "system";
+}
+
+function systemPrefersDark(): boolean {
+ if (typeof window === "undefined" || !window.matchMedia) return false;
+ return window.matchMedia(DARK_QUERY).matches;
+}
+
+function applyDarkClass(isDark: boolean) {
+ const root = document.documentElement;
+ if (isDark) root.classList.add("dark");
+ else root.classList.remove("dark");
+}
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [theme, setThemeState] = useState(() => readStoredTheme());
+ const [systemDark, setSystemDark] = useState(() => systemPrefersDark());
+
+ const resolvedTheme: ResolvedTheme = theme === "system" ? (systemDark ? "dark" : "light") : theme;
+
+ useEffect(() => {
+ applyDarkClass(resolvedTheme === "dark");
+ }, [resolvedTheme]);
+
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.matchMedia) return;
+ const mql = window.matchMedia(DARK_QUERY);
+ const handler = (event: { matches: boolean }) => setSystemDark(event.matches);
+ if (mql.addEventListener) {
+ mql.addEventListener("change", handler);
+ return () => mql.removeEventListener("change", handler);
+ }
+ mql.addListener(handler);
+ return () => mql.removeListener(handler);
+ }, []);
+
+ const setTheme = useCallback((next: Theme) => {
+ setThemeState(next);
+ try {
+ window.localStorage.setItem(STORAGE_KEY, next);
+ } catch {
+ // localStorage unavailable — ignore
+ }
+ }, []);
+
+ const value = useMemo(
+ () => ({ theme, resolvedTheme, setTheme }),
+ [theme, resolvedTheme, setTheme],
+ );
+
+ return {children};
+}
+
+export function useTheme(): ThemeContextValue {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
+ return ctx;
+}
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..0e0ea3f
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,27 @@
+import { Sun, Moon, Monitor } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useTheme, type Theme } from "@/components/ThemeProvider";
+
+const NEXT: Record = {
+ light: "dark",
+ dark: "system",
+ system: "light",
+};
+
+export function ThemeToggle() {
+ const { theme, setTheme } = useTheme();
+
+ const Icon = theme === "dark" ? Moon : theme === "light" ? Sun : Monitor;
+
+ return (
+
+ );
+}
diff --git a/src/components/__tests__/RecordingToolbar.test.tsx b/src/components/__tests__/RecordingToolbar.test.tsx
new file mode 100644
index 0000000..db11ccb
--- /dev/null
+++ b/src/components/__tests__/RecordingToolbar.test.tsx
@@ -0,0 +1,77 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { RecordingToolbar } from "@/components/RecordingToolbar";
+import type { Recording } from "@/types/recording";
+
+function makeRecording(tags: string[]): Recording {
+ return {
+ dirName: tags.join("-"),
+ data: {
+ id: "rec_" + tags.join("_"),
+ title: "Test",
+ description: "",
+ duration: 100,
+ language: "en",
+ recording_at: null,
+ created_at: "2026-04-22T09:00:00Z",
+ tags: tags.map((name) => ({ name })),
+ transcript: [],
+ summary: {},
+ },
+ analysis: null,
+ markdown: "",
+ analysisMarkdown: null,
+ };
+}
+
+const noop = vi.fn();
+
+describe("RecordingToolbar tag filter", () => {
+ it("scrolls horizontally and does not wrap when many tags exist", () => {
+ const tags = Array.from({ length: 30 }, (_, i) => `tag-${i.toString().padStart(2, "0")}`);
+ render(
+ ,
+ );
+
+ // Find the row by picking any tag chip and walking up to its row container.
+ const firstTag = screen.getByText("tag-00");
+ const row = firstTag.closest('[data-testid="tag-filter-row"]');
+ expect(row).not.toBeNull();
+ const className = row!.className;
+
+ // The row should scroll horizontally rather than wrapping.
+ expect(className).toMatch(/overflow-x-auto/);
+ // Chips must stay on a single line.
+ expect(className).toMatch(/flex-nowrap|whitespace-nowrap/);
+ // The leading icon should not shrink.
+ const icon = row!.querySelector("svg");
+ expect(icon?.getAttribute("class") || "").toMatch(/shrink-0|flex-shrink-0/);
+ });
+
+ it("renders nothing when there are no tags", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByTestId("tag-filter-row")).toBeNull();
+ });
+});
diff --git a/src/components/__tests__/ThemeProvider.test.tsx b/src/components/__tests__/ThemeProvider.test.tsx
new file mode 100644
index 0000000..e2a3bb8
--- /dev/null
+++ b/src/components/__tests__/ThemeProvider.test.tsx
@@ -0,0 +1,146 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { render, screen, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ThemeProvider, useTheme } from "@/components/ThemeProvider";
+
+const STORAGE_KEY = "seam-theme";
+
+function Probe() {
+ const { theme, resolvedTheme, setTheme } = useTheme();
+ return (
+
+ {theme}
+ {resolvedTheme}
+
+
+
+
+ );
+}
+
+type MediaQueryListener = (event: { matches: boolean }) => void;
+
+function installMatchMedia(initialMatches: boolean) {
+ const listeners = new Set();
+ const mql = {
+ matches: initialMatches,
+ media: "(prefers-color-scheme: dark)",
+ addEventListener: (_: string, cb: MediaQueryListener) => listeners.add(cb),
+ removeEventListener: (_: string, cb: MediaQueryListener) => listeners.delete(cb),
+ addListener: (cb: MediaQueryListener) => listeners.add(cb),
+ removeListener: (cb: MediaQueryListener) => listeners.delete(cb),
+ dispatchEvent: () => true,
+ onchange: null,
+ };
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => mql),
+ );
+ Object.defineProperty(window, "matchMedia", { writable: true, value: () => mql });
+ return {
+ fire(matches: boolean) {
+ mql.matches = matches;
+ listeners.forEach((cb) => cb({ matches }));
+ },
+ };
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.classList.remove("dark");
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("ThemeProvider / useTheme", () => {
+ it("defaults to system theme when nothing in localStorage", () => {
+ installMatchMedia(false);
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("theme")).toHaveTextContent("system");
+ expect(screen.getByTestId("resolved")).toHaveTextContent("light");
+ expect(document.documentElement).not.toHaveClass("dark");
+ });
+
+ it("loads saved theme from localStorage on mount", () => {
+ installMatchMedia(false);
+ localStorage.setItem(STORAGE_KEY, "dark");
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("theme")).toHaveTextContent("dark");
+ expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
+ expect(document.documentElement).toHaveClass("dark");
+ });
+
+ it("applies dark class when system prefers dark and theme is system", () => {
+ installMatchMedia(true);
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("theme")).toHaveTextContent("system");
+ expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
+ expect(document.documentElement).toHaveClass("dark");
+ });
+
+ it("setTheme persists to localStorage and updates the dark class", async () => {
+ installMatchMedia(false);
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByText("set-dark"));
+ expect(localStorage.getItem(STORAGE_KEY)).toBe("dark");
+ expect(document.documentElement).toHaveClass("dark");
+ expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
+
+ await user.click(screen.getByText("set-light"));
+ expect(localStorage.getItem(STORAGE_KEY)).toBe("light");
+ expect(document.documentElement).not.toHaveClass("dark");
+ expect(screen.getByTestId("resolved")).toHaveTextContent("light");
+ });
+
+ it("reacts to system preference changes when theme is system", () => {
+ const mql = installMatchMedia(false);
+ render(
+
+
+ ,
+ );
+ expect(document.documentElement).not.toHaveClass("dark");
+
+ act(() => mql.fire(true));
+ expect(document.documentElement).toHaveClass("dark");
+ expect(screen.getByTestId("resolved")).toHaveTextContent("dark");
+
+ act(() => mql.fire(false));
+ expect(document.documentElement).not.toHaveClass("dark");
+ });
+
+ it("ignores system preference changes when theme is explicit", async () => {
+ const mql = installMatchMedia(false);
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByText("set-light"));
+ act(() => mql.fire(true));
+ expect(document.documentElement).not.toHaveClass("dark");
+ expect(screen.getByTestId("resolved")).toHaveTextContent("light");
+ });
+});
diff --git a/src/components/__tests__/ThemeToggle.test.tsx b/src/components/__tests__/ThemeToggle.test.tsx
new file mode 100644
index 0000000..2c7d3ad
--- /dev/null
+++ b/src/components/__tests__/ThemeToggle.test.tsx
@@ -0,0 +1,66 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ThemeProvider } from "@/components/ThemeProvider";
+import { ThemeToggle } from "@/components/ThemeToggle";
+
+function installMatchMedia(matches: boolean) {
+ const mql = {
+ matches,
+ media: "(prefers-color-scheme: dark)",
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: () => true,
+ onchange: null,
+ };
+ Object.defineProperty(window, "matchMedia", { writable: true, value: () => mql });
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.classList.remove("dark");
+ installMatchMedia(false);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("ThemeToggle", () => {
+ it("cycles through light → dark → system on click", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByRole("button", { name: /theme/i });
+
+ // Default is "system" (no localStorage). First click → light.
+ await user.click(button);
+ expect(localStorage.getItem("seam-theme")).toBe("light");
+
+ // Second click → dark.
+ await user.click(button);
+ expect(localStorage.getItem("seam-theme")).toBe("dark");
+ expect(document.documentElement).toHaveClass("dark");
+
+ // Third click → back to system.
+ await user.click(button);
+ expect(localStorage.getItem("seam-theme")).toBe("system");
+ });
+
+ it("has an accessible name reflecting the current theme", () => {
+ localStorage.setItem("seam-theme", "dark");
+ render(
+
+
+ ,
+ );
+ const button = screen.getByRole("button", { name: /theme/i });
+ expect(button).toHaveAttribute("aria-label", expect.stringMatching(/dark/i));
+ });
+});
diff --git a/src/index.css b/src/index.css
index 9e38095..194ae63 100644
--- a/src/index.css
+++ b/src/index.css
@@ -5,7 +5,7 @@
@custom-variant dark (&:is(.dark *));
-@theme inline {
+@theme {
/* White + #2b2b2b palette */
--color-background: #ffffff;
--color-foreground: #2b2b2b;
@@ -48,6 +48,32 @@
}
@layer base {
+ .dark {
+ --color-background: #1a1a1a;
+ --color-foreground: #f5f5f5;
+ --color-card: #232323;
+ --color-card-foreground: #f5f5f5;
+ --color-popover: #232323;
+ --color-popover-foreground: #f5f5f5;
+ --color-primary: #f5f5f5;
+ --color-primary-foreground: #1a1a1a;
+ --color-secondary: #2b2b2b;
+ --color-secondary-foreground: #f5f5f5;
+ --color-muted: #2b2b2b;
+ --color-muted-foreground: #a3a3a3;
+ --color-accent: #2b2b2b;
+ --color-accent-foreground: #f5f5f5;
+ --color-destructive: #ef4444;
+ --color-destructive-foreground: #ef4444;
+ --color-border: #404040;
+ --color-input: #404040;
+ --color-ring: #d4d4d4;
+ --color-chart-1: #f5f5f5;
+ --color-chart-2: #d4d4d4;
+ --color-chart-3: #a3a3a3;
+ --color-chart-4: #737373;
+ --color-chart-5: #404040;
+ }
* {
@apply border-border outline-ring/50;
}
diff --git a/src/lib/tag-colors.ts b/src/lib/tag-colors.ts
index 3cbae80..7e6c4ea 100644
--- a/src/lib/tag-colors.ts
+++ b/src/lib/tag-colors.ts
@@ -1,16 +1,64 @@
const TAG_COLORS = [
- { bg: "bg-blue-100", text: "text-blue-800", border: "border-blue-200" },
- { bg: "bg-green-100", text: "text-green-800", border: "border-green-200" },
- { bg: "bg-purple-100", text: "text-purple-800", border: "border-purple-200" },
- { bg: "bg-amber-100", text: "text-amber-800", border: "border-amber-200" },
- { bg: "bg-rose-100", text: "text-rose-800", border: "border-rose-200" },
- { bg: "bg-cyan-100", text: "text-cyan-800", border: "border-cyan-200" },
- { bg: "bg-orange-100", text: "text-orange-800", border: "border-orange-200" },
- { bg: "bg-indigo-100", text: "text-indigo-800", border: "border-indigo-200" },
- { bg: "bg-emerald-100", text: "text-emerald-800", border: "border-emerald-200" },
- { bg: "bg-pink-100", text: "text-pink-800", border: "border-pink-200" },
- { bg: "bg-teal-100", text: "text-teal-800", border: "border-teal-200" },
- { bg: "bg-yellow-100", text: "text-yellow-800", border: "border-yellow-200" },
+ {
+ bg: "bg-blue-100 dark:bg-blue-950/40",
+ text: "text-blue-800 dark:text-blue-300",
+ border: "border-blue-200 dark:border-blue-900",
+ },
+ {
+ bg: "bg-green-100 dark:bg-green-950/40",
+ text: "text-green-800 dark:text-green-300",
+ border: "border-green-200 dark:border-green-900",
+ },
+ {
+ bg: "bg-purple-100 dark:bg-purple-950/40",
+ text: "text-purple-800 dark:text-purple-300",
+ border: "border-purple-200 dark:border-purple-900",
+ },
+ {
+ bg: "bg-amber-100 dark:bg-amber-950/40",
+ text: "text-amber-800 dark:text-amber-300",
+ border: "border-amber-200 dark:border-amber-900",
+ },
+ {
+ bg: "bg-rose-100 dark:bg-rose-950/40",
+ text: "text-rose-800 dark:text-rose-300",
+ border: "border-rose-200 dark:border-rose-900",
+ },
+ {
+ bg: "bg-cyan-100 dark:bg-cyan-950/40",
+ text: "text-cyan-800 dark:text-cyan-300",
+ border: "border-cyan-200 dark:border-cyan-900",
+ },
+ {
+ bg: "bg-orange-100 dark:bg-orange-950/40",
+ text: "text-orange-800 dark:text-orange-300",
+ border: "border-orange-200 dark:border-orange-900",
+ },
+ {
+ bg: "bg-indigo-100 dark:bg-indigo-950/40",
+ text: "text-indigo-800 dark:text-indigo-300",
+ border: "border-indigo-200 dark:border-indigo-900",
+ },
+ {
+ bg: "bg-emerald-100 dark:bg-emerald-950/40",
+ text: "text-emerald-800 dark:text-emerald-300",
+ border: "border-emerald-200 dark:border-emerald-900",
+ },
+ {
+ bg: "bg-pink-100 dark:bg-pink-950/40",
+ text: "text-pink-800 dark:text-pink-300",
+ border: "border-pink-200 dark:border-pink-900",
+ },
+ {
+ bg: "bg-teal-100 dark:bg-teal-950/40",
+ text: "text-teal-800 dark:text-teal-300",
+ border: "border-teal-200 dark:border-teal-900",
+ },
+ {
+ bg: "bg-yellow-100 dark:bg-yellow-950/40",
+ text: "text-yellow-800 dark:text-yellow-300",
+ border: "border-yellow-200 dark:border-yellow-900",
+ },
];
function hashString(str: string): number {
diff --git a/src/main.tsx b/src/main.tsx
index 756b7c1..85e9a6b 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route, Outlet } from "react-router";
import "./index.css";
import { Navbar } from "./components/Navbar";
+import { ThemeProvider } from "./components/ThemeProvider";
import App from "./App";
import { RecordingDetail } from "./pages/RecordingDetail";
import { PeoplePage } from "./pages/PeoplePage";
@@ -22,16 +23,18 @@ function Layout() {
createRoot(document.getElementById("root")!).render(
-
-
- }>
- } />
- } />
- } />
- } />
- } />
-
-
-
+
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
,
);
diff --git a/src/pages/PeoplePage.tsx b/src/pages/PeoplePage.tsx
index b6e8cc2..6ac2193 100644
--- a/src/pages/PeoplePage.tsx
+++ b/src/pages/PeoplePage.tsx
@@ -153,7 +153,10 @@ export function PeoplePage() {
{pending.map((p) => (
-
+
@@ -167,7 +170,7 @@ export function PeoplePage() {
{/* Tabs */}
-
+
{TABS.map(({ id, label, icon: Icon, color }) => (