Skip to content

Commit a7b0dfc

Browse files
Add Theme Switcher (#319)
* feat: add theme switcher * Create spicy-islands-heal.md --------- Co-authored-by: Matt Pocock <[email protected]>
1 parent 634b6bc commit a7b0dfc

File tree

9 files changed

+206
-109
lines changed

9 files changed

+206
-109
lines changed

.changeset/spicy-islands-heal.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"evalite": patch
3+
---
4+
5+
Added a theme switcher for light/dark mode to Evalite's UI

apps/evalite-ui/app/components/page-layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ThemeToggle } from "./theme-toggle";
12
import {
23
Breadcrumb,
34
BreadcrumbItem,
@@ -19,7 +20,7 @@ export const InnerPageLayout = ({
1920
}) => {
2021
return (
2122
<div className="flex flex-col bg-background relative flex-1 min-h-svh min-w-0">
22-
<header className="sticky top-0 flex h-14 shrink-0 items-center gap-2 bg-background z-10">
23+
<header className="sticky top-0 flex h-14 shrink-0 items-center gap-2 bg-background z-10 justify-between">
2324
<div className="flex flex-1 items-center gap-2 px-3">
2425
<SidebarTrigger />
2526
<Separator orientation="vertical" className="mr-2 h-4" />
@@ -33,6 +34,9 @@ export const InnerPageLayout = ({
3334
</BreadcrumbList>
3435
</Breadcrumb>
3536
</div>
37+
<div className="flex items-center gap-2 px-3">
38+
<ThemeToggle />
39+
</div>
3640
</header>
3741
<div className="flex-1 p-4">{children}</div>
3842
</div>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createContext, useState } from "react";
2+
3+
export const ThemeContext = createContext<{
4+
theme: "light" | "dark" | "system";
5+
setTheme: (theme: "light" | "dark") => void;
6+
}>({
7+
theme: "system",
8+
setTheme: () => {},
9+
});
10+
11+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
12+
const [theme, setTheme] = useState<"light" | "dark">(() => {
13+
const preferredTheme = localStorage.getItem("theme");
14+
return preferredTheme as "light" | "dark";
15+
});
16+
17+
function handleThemeChange(theme: "light" | "dark") {
18+
const root = document.documentElement;
19+
20+
root.classList.toggle("disable-transitions", true);
21+
22+
setTheme(theme);
23+
localStorage.setItem("theme", theme);
24+
root.classList.toggle("dark", theme === "dark");
25+
26+
requestAnimationFrame(() => {
27+
root.classList.toggle("disable-transitions", false);
28+
});
29+
}
30+
31+
return (
32+
<ThemeContext.Provider value={{ theme, setTheme: handleThemeChange }}>
33+
{children}
34+
</ThemeContext.Provider>
35+
);
36+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ContrastIcon } from "lucide-react";
2+
import { useTheme } from "~/hooks/use-theme";
3+
import { Button } from "./ui/button";
4+
5+
export function ThemeToggle() {
6+
const { theme, setTheme } = useTheme();
7+
8+
return (
9+
<Button
10+
variant="ghost"
11+
size="icon"
12+
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
13+
>
14+
<ContrastIcon className="size-4 dark:rotate-180" />
15+
</Button>
16+
);
17+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { use } from "react";
2+
import { ThemeContext } from "../components/theme-provider";
3+
4+
export function useTheme() {
5+
const context = use(ThemeContext);
6+
if (!context) {
7+
throw new Error("useTheme must be used within a ThemeProvider");
8+
}
9+
return context;
10+
}

apps/evalite-ui/app/routes/__root.tsx

Lines changed: 105 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from "~/components/ui/input-group";
4545
import { isStaticMode, triggerRerun } from "~/sdk";
4646
import { cn } from "~/lib/utils";
47+
import { ThemeProvider } from "~/components/theme-provider";
4748

4849
const TanStackRouterDevtools =
4950
process.env.NODE_ENV === "production"
@@ -195,112 +196,114 @@ export default function App() {
195196
}
196197

197198
return (
198-
<SidebarProvider className="w-full">
199-
<Sidebar className="border-r-0">
200-
<SidebarHeader>
201-
<SidebarMenu>
202-
<SidebarMenuItem className="border-b md:-mx-3 -mx-2 md:px-3 px-2 pb-1.5">
203-
<div className="px-2 py-1">
204-
<Logo />
205-
</div>
206-
</SidebarMenuItem>
207-
</SidebarMenu>
208-
</SidebarHeader>
209-
<SidebarContent>
210-
<SidebarGroup>
211-
<div className="px-2">
212-
<p className="text-xs font-medium text-sidebar-foreground/70 mb-2">
213-
Summary
214-
</p>
215-
<div className="flex items-center justify-between">
216-
<div className="text-foreground/60 font-medium text-2xl">
217-
<Score
218-
score={score}
219-
state={getScoreState({
220-
score,
221-
prevScore,
222-
status: runStatus,
223-
})}
224-
iconClassName="size-4"
225-
hasScores={hasScores}
226-
/>
199+
<ThemeProvider>
200+
<SidebarProvider className="w-full">
201+
<Sidebar className="border-r-0">
202+
<SidebarHeader>
203+
<SidebarMenu>
204+
<SidebarMenuItem className="border-b md:-mx-3 -mx-2 md:px-3 px-2 pb-1.5">
205+
<div className="px-2 py-1">
206+
<Logo />
227207
</div>
228-
{!isStaticMode() && (
229-
<button
230-
onClick={handleRerun}
231-
disabled={serverState.type === "running" || isRerunning}
232-
className={cn(
233-
"flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors",
234-
serverState.type === "running" || isRerunning
235-
? "text-foreground/40 cursor-not-allowed"
236-
: "text-foreground/80 hover:bg-foreground/20"
237-
)}
238-
title="Rerun all evals"
239-
>
240-
<RotateCw
241-
className={cn("size-3", isRerunning && "animate-spin")}
208+
</SidebarMenuItem>
209+
</SidebarMenu>
210+
</SidebarHeader>
211+
<SidebarContent>
212+
<SidebarGroup>
213+
<div className="px-2">
214+
<p className="text-xs font-medium text-sidebar-foreground/70 mb-2">
215+
Summary
216+
</p>
217+
<div className="flex items-center justify-between">
218+
<div className="text-foreground/60 font-medium text-2xl">
219+
<Score
220+
score={score}
221+
state={getScoreState({
222+
score,
223+
prevScore,
224+
status: runStatus,
225+
})}
226+
iconClassName="size-4"
227+
hasScores={hasScores}
242228
/>
243-
Rerun
244-
</button>
245-
)}
229+
</div>
230+
{!isStaticMode() && (
231+
<button
232+
onClick={handleRerun}
233+
disabled={serverState.type === "running" || isRerunning}
234+
className={cn(
235+
"flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors",
236+
serverState.type === "running" || isRerunning
237+
? "text-foreground/40 cursor-not-allowed"
238+
: "text-foreground/80 hover:bg-foreground/20"
239+
)}
240+
title="Rerun all evals"
241+
>
242+
<RotateCw
243+
className={cn("size-3", isRerunning && "animate-spin")}
244+
/>
245+
Rerun
246+
</button>
247+
)}
248+
</div>
246249
</div>
247-
</div>
248-
</SidebarGroup>
249-
<SidebarGroup>
250-
<SidebarGroupLabel>Suites</SidebarGroupLabel>
251-
<InputGroup className="h-8 mb-2">
252-
<InputGroupAddon align="inline-start">
253-
<InputGroupText>
254-
<Search />
255-
</InputGroupText>
256-
</InputGroupAddon>
257-
<InputGroupInput
258-
placeholder="Search"
259-
value={searchQuery ?? ""}
260-
onChange={(e) => handleSearchChange(e.target.value)}
261-
/>
262-
{searchQuery && (
263-
<InputGroupAddon align="inline-end">
264-
<InputGroupButton
265-
size="icon-xs"
266-
onClick={() => handleSearchChange("")}
267-
>
268-
<X />
269-
</InputGroupButton>
250+
</SidebarGroup>
251+
<SidebarGroup>
252+
<SidebarGroupLabel>Suites</SidebarGroupLabel>
253+
<InputGroup className="h-8 mb-2">
254+
<InputGroupAddon align="inline-start">
255+
<InputGroupText>
256+
<Search />
257+
</InputGroupText>
270258
</InputGroupAddon>
271-
)}
272-
</InputGroup>
273-
<SidebarMenu>
274-
{filteredGroupedEvals.map((item) => {
275-
if (item.type === "single") {
276-
return (
277-
<EvalSidebarItem
278-
key={`eval-${item.suite.name}`}
279-
name={item.suite.name}
280-
score={item.suite.score}
281-
state={item.suite.state}
282-
suiteStatus={item.suite.suiteStatus}
283-
hasScores={item.suite.hasScores}
284-
/>
285-
);
286-
} else {
287-
return (
288-
<VariantGroup
289-
key={`group-${item.groupName}`}
290-
groupName={item.groupName}
291-
variants={item.variants}
292-
/>
293-
);
294-
}
295-
})}
296-
</SidebarMenu>
297-
</SidebarGroup>
298-
</SidebarContent>
299-
</Sidebar>
300-
<Outlet />
301-
<TanStackRouterDevtools />
302-
<ReactQueryDevtools />
303-
</SidebarProvider>
259+
<InputGroupInput
260+
placeholder="Search"
261+
value={searchQuery ?? ""}
262+
onChange={(e) => handleSearchChange(e.target.value)}
263+
/>
264+
{searchQuery && (
265+
<InputGroupAddon align="inline-end">
266+
<InputGroupButton
267+
size="icon-xs"
268+
onClick={() => handleSearchChange("")}
269+
>
270+
<X />
271+
</InputGroupButton>
272+
</InputGroupAddon>
273+
)}
274+
</InputGroup>
275+
<SidebarMenu>
276+
{filteredGroupedEvals.map((item) => {
277+
if (item.type === "single") {
278+
return (
279+
<EvalSidebarItem
280+
key={`eval-${item.suite.name}`}
281+
name={item.suite.name}
282+
score={item.suite.score}
283+
state={item.suite.state}
284+
suiteStatus={item.suite.suiteStatus}
285+
hasScores={item.suite.hasScores}
286+
/>
287+
);
288+
} else {
289+
return (
290+
<VariantGroup
291+
key={`group-${item.groupName}`}
292+
groupName={item.groupName}
293+
variants={item.variants}
294+
/>
295+
);
296+
}
297+
})}
298+
</SidebarMenu>
299+
</SidebarGroup>
300+
</SidebarContent>
301+
</Sidebar>
302+
<Outlet />
303+
<TanStackRouterDevtools />
304+
<ReactQueryDevtools />
305+
</SidebarProvider>
306+
</ThemeProvider>
304307
);
305308
}
306309

apps/evalite-ui/app/tailwind.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,8 @@
159159
body {
160160
@apply bg-background text-foreground;
161161
}
162+
163+
.disable-transitions * {
164+
transition: none !important;
165+
}
162166
}

apps/evalite-ui/index.html

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,22 @@
1515
<link rel="icon" href="/assets/favicon.ico" sizes="any" />
1616
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml" />
1717
<script>
18-
// Set the theme to system default and listen for changes
19-
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
20-
function updateTheme(e) {
21-
document.documentElement.classList.toggle("dark", e.matches);
18+
let preferredTheme = localStorage.getItem("theme") || "system";
19+
20+
if (preferredTheme === "system") {
21+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
22+
function updateTheme(e) {
23+
document.documentElement.classList.toggle("dark", e.matches);
24+
localStorage.setItem("theme", e.matches ? "dark" : "light");
25+
}
26+
updateTheme(mediaQuery);
27+
mediaQuery.addEventListener("change", updateTheme);
28+
} else {
29+
document.documentElement.classList.toggle(
30+
"dark",
31+
preferredTheme === "dark"
32+
);
2233
}
23-
updateTheme(mediaQuery);
24-
mediaQuery.addEventListener("change", updateTheme);
2534
</script>
2635
</head>
2736
<body>

apps/evalite-ui/vite.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,13 @@ export default defineConfig({
1717
tsconfigPaths(),
1818
viteReact(),
1919
],
20+
server: {
21+
proxy: {
22+
"/api": {
23+
target: "http://localhost:3006",
24+
changeOrigin: true,
25+
ws: true,
26+
},
27+
},
28+
},
2029
});

0 commit comments

Comments
 (0)