From f1f49eb03941ef4ab3a05807c136d2a81487d9e0 Mon Sep 17 00:00:00 2001 From: Chris Woodson Date: Thu, 30 Apr 2026 23:20:21 -0400 Subject: [PATCH 1/2] Add dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds light/dark/system theme switching to the dashboard. - ThemeProvider context (src/components/ThemeProvider.tsx) reads localStorage, listens to prefers-color-scheme, and toggles the `dark` class on . - ThemeToggle button in the navbar cycles light → dark → system. - Dark color tokens added under `.dark` in src/index.css. - Switched `@theme inline` to `@theme` so Tailwind v4 utilities reference the variables at runtime rather than inlining the light values, which is what lets the .dark override actually take effect. - Inline script in index.html applies the stored theme before paint to prevent flash-of-light-theme on initial load. - Migrated hardcoded hex colors in App.tsx, SyncPanel, SyncPage, RecordingDetail, PeoplePage, and lib/tag-colors.ts to either semantic tokens or `dark:` variants so every page renders correctly in both modes. Tests: ThemeProvider and ThemeToggle covered by RTL tests (cycling, persistence, system-pref change handling, accessible name). Co-Authored-By: Claude Opus 4.7 (1M context) --- index.html | 12 ++ src/App.tsx | 9 +- src/components/Navbar.tsx | 2 + src/components/SyncPanel.tsx | 25 ++- src/components/ThemeProvider.tsx | 78 ++++++++++ src/components/ThemeToggle.tsx | 27 ++++ .../__tests__/ThemeProvider.test.tsx | 146 ++++++++++++++++++ src/components/__tests__/ThemeToggle.test.tsx | 66 ++++++++ src/index.css | 28 +++- src/lib/tag-colors.ts | 72 +++++++-- src/main.tsx | 25 +-- src/pages/PeoplePage.tsx | 7 +- src/pages/RecordingDetail.tsx | 2 +- src/pages/SyncPage.tsx | 33 ++-- 14 files changed, 479 insertions(+), 53 deletions(-) create mode 100644 src/components/ThemeProvider.tsx create mode 100644 src/components/ThemeToggle.tsx create mode 100644 src/components/__tests__/ThemeProvider.test.tsx create mode 100644 src/components/__tests__/ThemeToggle.test.tsx diff --git a/index.html b/index.html index 4936f6a..7c576f8 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,18 @@ Seam +
diff --git a/src/App.tsx b/src/App.tsx index 811b983..1039c33 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -124,9 +124,12 @@ export default function App() { { label: "Action Items", value: stats.totalActions }, { label: "Open Questions", value: stats.openQuestions }, ].map((stat) => ( -
-
{stat.value}
-
{stat.label}
+
+
{stat.value}
+
{stat.label}
))}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 23d1991..79d432a 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,5 +1,6 @@ import { useNavigate, useLocation } from "react-router"; import { Button } from "@/components/ui/button"; +import { ThemeToggle } from "@/components/ThemeToggle"; import { useSync } from "@/hooks/useSync"; import { Users, RefreshCw, Settings } from "lucide-react"; @@ -44,6 +45,7 @@ export function Navbar() { )} + + ); +} 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 }) => (