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 index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Seam</title>
<script>
(function () {
try {
var stored = localStorage.getItem("seam-theme");
var prefersDark =
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
var isDark =
stored === "dark" || ((stored === null || stored === "system") && prefersDark);
if (isDark) document.documentElement.classList.add("dark");
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
9 changes: 6 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,12 @@ export default function App() {
{ label: "Action Items", value: stats.totalActions },
{ label: "Open Questions", value: stats.openQuestions },
].map((stat) => (
<div key={stat.label} className="rounded-lg border bg-[#2b2b2b] p-4 text-center">
<div className="text-2xl font-bold text-white">{stat.value}</div>
<div className="text-xs text-[#a3a3a3]">{stat.label}</div>
<div
key={stat.label}
className="rounded-lg border bg-primary p-4 text-center text-primary-foreground"
>
<div className="text-2xl font-bold">{stat.value}</div>
<div className="text-xs opacity-70">{stat.label}</div>
</div>
))}
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -44,6 +45,7 @@ export function Navbar() {
</span>
)}
</Button>
<ThemeToggle />
<Button
variant={isActive("/settings") ? "default" : "outline"}
size="sm"
Expand Down
9 changes: 6 additions & 3 deletions src/components/RecordingToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,16 @@ export function RecordingToolbar({

{/* Tag filter */}
{tags.length > 0 && (
<div className="flex items-center gap-1.5 text-sm">
<Tag className="h-3 w-3 text-muted-foreground" />
<div
data-testid="tag-filter-row"
className="flex items-center gap-1.5 text-sm w-full overflow-x-auto flex-nowrap whitespace-nowrap pb-1"
>
<Tag className="h-3 w-3 text-muted-foreground shrink-0" />
{tags.map((tag) => (
<Badge
key={tag}
variant={filterTag === tag ? "default" : "secondary"}
className={`cursor-pointer text-xs ${filterTag === tag ? "" : tagClassName(tag)}`}
className={`cursor-pointer text-xs shrink-0 ${filterTag === tag ? "" : tagClassName(tag)}`}
onClick={() => onFilterTagChange(filterTag === tag ? "" : tag)}
>
{tag}
Expand Down
25 changes: 17 additions & 8 deletions src/components/SyncPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,30 @@ function StatusBadge({ status }: { status: SyncStatus }) {
switch (status) {
case "running":
return (
<Badge variant="secondary" className="bg-amber-100 text-amber-800 gap-1">
<Badge
variant="secondary"
className="bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300 gap-1"
>
<RefreshCw className="h-3 w-3 animate-spin" />
Running
</Badge>
);
case "done":
return (
<Badge variant="secondary" className="bg-green-100 text-green-800 gap-1">
<Badge
variant="secondary"
className="bg-green-100 text-green-800 dark:bg-green-950/40 dark:text-green-300 gap-1"
>
<CheckCircle2 className="h-3 w-3" />
Done
</Badge>
);
case "error":
return (
<Badge variant="secondary" className="bg-red-100 text-red-800 gap-1">
<Badge
variant="secondary"
className="bg-red-100 text-red-800 dark:bg-red-950/40 dark:text-red-300 gap-1"
>
<XCircle className="h-3 w-3" />
Error
</Badge>
Expand Down Expand Up @@ -134,7 +143,7 @@ export function SyncPanel({
</div>

{error && (
<div className="text-sm text-red-600 bg-red-50 rounded-md p-3 border border-red-200">
<div className="text-sm text-red-600 dark:text-red-300 bg-red-50 dark:bg-red-950/40 rounded-md p-3 border border-red-200 dark:border-red-900">
{error}
</div>
)}
Expand All @@ -147,10 +156,10 @@ export function SyncPanel({
Logs
{logs.length > 0 && <span>({logs.length} lines)</span>}
</div>
<ScrollArea className="h-64 rounded-md border bg-[#1e1e1e] p-3">
<pre className="text-xs font-mono text-[#d4d4d4] whitespace-pre-wrap">
<ScrollArea className="h-64 rounded-md border bg-neutral-900 p-3">
<pre className="text-xs font-mono text-neutral-300 whitespace-pre-wrap">
{logs.length === 0 ? (
<span className="text-[#737373]">No logs yet. Click sync to start.</span>
<span className="text-neutral-500">No logs yet. Click sync to start.</span>
) : (
logs.map((line, i) => (
<div
Expand All @@ -159,7 +168,7 @@ export function SyncPanel({
line.startsWith("[stderr]")
? "text-red-400"
: line.includes("===")
? "text-[#569cd6] font-bold"
? "text-sky-400 font-bold"
: line.includes("ERROR")
? "text-red-400"
: line.includes("Done") || line.includes("completed")
Expand Down
78 changes: 78 additions & 0 deletions src/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from "react";

export type Theme = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";

const STORAGE_KEY = "seam-theme";
const DARK_QUERY = "(prefers-color-scheme: dark)";

interface ThemeContextValue {
theme: Theme;
resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(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<Theme>(() => readStoredTheme());
const [systemDark, setSystemDark] = useState<boolean>(() => 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<ThemeContextValue>(
() => ({ theme, resolvedTheme, setTheme }),
[theme, resolvedTheme, setTheme],
);

return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
return ctx;
}
27 changes: 27 additions & 0 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -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<Theme, Theme> = {
light: "dark",
dark: "system",
system: "light",
};

export function ThemeToggle() {
const { theme, setTheme } = useTheme();

const Icon = theme === "dark" ? Moon : theme === "light" ? Sun : Monitor;

return (
<Button
variant="outline"
size="sm"
onClick={() => setTheme(NEXT[theme])}
aria-label={`Theme: ${theme} (click to change)`}
className="gap-1.5"
>
<Icon className="h-4 w-4" />
</Button>
);
}
77 changes: 77 additions & 0 deletions src/components/__tests__/RecordingToolbar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<RecordingToolbar
recordings={[makeRecording(tags)]}
sortField="date"
sortDir="desc"
filterType=""
filterTag=""
onSortFieldChange={noop}
onSortDirToggle={noop}
onFilterTypeChange={noop}
onFilterTagChange={noop}
/>,
);

// 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(
<RecordingToolbar
recordings={[makeRecording([])]}
sortField="date"
sortDir="desc"
filterType=""
filterTag=""
onSortFieldChange={noop}
onSortDirToggle={noop}
onFilterTypeChange={noop}
onFilterTagChange={noop}
/>,
);
expect(screen.queryByTestId("tag-filter-row")).toBeNull();
});
});
Loading
Loading