feat(ui): light/dark theme toggle and light mode color support

Add ThemeContext with localStorage persistence and FOUC-preventing
inline script. Add theme toggle button in sidebar. Update status
badges, toast notifications, live indicators, and approval cards
with dark: prefixed classes for proper light mode rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-26 16:33:29 -06:00
parent e2c5b6698c
commit 5cd12dec89
13 changed files with 245 additions and 92 deletions

View File

@@ -7,10 +7,10 @@ import { timeAgo } from "../lib/timeAgo";
import type { Approval, Agent } from "@paperclip/shared";
function statusIcon(status: string) {
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-400" />;
if (status === "rejected") return <XCircle className="h-3.5 w-3.5 text-red-400" />;
if (status === "revision_requested") return <Clock className="h-3.5 w-3.5 text-amber-400" />;
if (status === "pending") return <Clock className="h-3.5 w-3.5 text-yellow-400" />;
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />;
if (status === "rejected") return <XCircle className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />;
if (status === "revision_requested") return <Clock className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />;
if (status === "pending") return <Clock className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400" />;
return null;
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState, type UIEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen } from "lucide-react";
import { BookOpen, Moon, Sun } from "lucide-react";
import { Outlet } from "react-router-dom";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
@@ -19,20 +19,24 @@ import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { useTheme } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
export function Layout() {
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
const { panelContent, closePanel } = usePanel();
const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany();
const { theme, toggleTheme } = useTheme();
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const nextTheme = theme === "dark" ? "light" : "dark";
const { data: health } = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
@@ -168,7 +172,25 @@ export function Layout() {
<Sidebar />
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
<div className="flex items-center gap-1">
<SidebarNavItem
to="/docs"
label="Documentation"
icon={BookOpen}
className="flex-1 min-w-0"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
) : (
@@ -185,7 +207,25 @@ export function Layout() {
</div>
</div>
<div className="border-t border-r border-border px-3 py-2">
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
<div className="flex items-center gap-1">
<SidebarNavItem
to="/docs"
label="Documentation"
icon={BookOpen}
className="flex-1 min-w-0"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
)}

View File

@@ -344,7 +344,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
<button
onClick={() => handleCancelRun(headerRun.id)}
disabled={cancellingRunIds.has(headerRun.id)}
className="inline-flex items-center gap-1 text-[10px] text-red-400 hover:text-red-300 disabled:opacity-50"
className="inline-flex items-center gap-1 text-[10px] text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
>
<Square className="h-2 w-2" fill="currentColor" />
{cancellingRunIds.has(headerRun.id) ? "Stopping…" : "Stop"}
@@ -352,7 +352,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
)}
<Link
to={`/agents/${headerRun.agentId}/runs/${headerRun.id}`}
className="inline-flex items-center gap-1 text-[10px] text-cyan-300 hover:text-cyan-200"
className="inline-flex items-center gap-1 text-[10px] text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200"
>
Open run
<ExternalLink className="h-2.5 w-2.5" />
@@ -376,13 +376,13 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
<span className="text-[10px] text-muted-foreground">{relativeTime(item.ts)}</span>
<div className={cn(
"min-w-0",
item.tone === "error" && "text-red-300",
item.tone === "warn" && "text-amber-300",
item.tone === "assistant" && "text-emerald-200",
item.tone === "tool" && "text-cyan-300",
item.tone === "error" && "text-red-600 dark:text-red-300",
item.tone === "warn" && "text-amber-600 dark:text-amber-300",
item.tone === "assistant" && "text-emerald-700 dark:text-emerald-200",
item.tone === "tool" && "text-cyan-600 dark:text-cyan-300",
item.tone === "info" && "text-foreground/80",
)}>
<Identity name={item.agentName} size="sm" className="text-cyan-400" />
<Identity name={item.agentName} size="sm" className="text-cyan-600 dark:text-cyan-400" />
<span className="text-muted-foreground"> [{item.runId.slice(0, 8)}] </span>
<span className="break-words">{item.text}</span>
</div>
@@ -396,7 +396,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
<div key={run.id} className="inline-flex items-center gap-1.5">
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 text-[10px] text-cyan-300 hover:text-cyan-200"
className="inline-flex items-center gap-1 text-[10px] text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200"
>
<Identity name={run.agentName} size="sm" /> {run.id.slice(0, 8)}
<ExternalLink className="h-2.5 w-2.5" />

View File

@@ -1,6 +1,7 @@
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils";
import { useTheme } from "../context/ThemeContext";
interface MarkdownBodyProps {
children: string;
@@ -8,10 +9,12 @@ interface MarkdownBodyProps {
}
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
const { theme } = useTheme();
return (
<div
className={cn(
"prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-headings:my-2 prose-headings:text-sm prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5 prose-code:break-all",
"prose prose-sm max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-headings:my-2 prose-headings:text-sm prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5 prose-code:break-all",
theme === "dark" && "prose-invert",
className,
)}
>

View File

@@ -118,7 +118,7 @@ export function SidebarAgents() {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-400">
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
{runCount} live
</span>
</span>

View File

@@ -8,6 +8,7 @@ interface SidebarNavItemProps {
label: string;
icon: LucideIcon;
end?: boolean;
className?: string;
badge?: number;
badgeTone?: "default" | "danger";
alert?: boolean;
@@ -19,6 +20,7 @@ export function SidebarNavItem({
label,
icon: Icon,
end,
className,
badge,
badgeTone = "default",
alert = false,
@@ -36,7 +38,8 @@ export function SidebarNavItem({
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
className,
)
}
>
@@ -53,7 +56,7 @@ export function SidebarNavItem({
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-400">{liveCount} live</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
</span>
)}
{badge != null && badge > 0 && (