MAESTRO: Style pass — dark mode, shared Layout with nav sidebar, responsive improvements, animations

- Enable Tailwind darkMode: 'class' with system preference detection
- Add shared Layout component with sidebar navigation, dark mode toggle, and mobile hamburger menu
- Add global CSS: focus-visible rings, smooth transitions, custom scrollbar, entrance animations
- Update all authenticated pages to use Layout wrapper via App.tsx route restructure
- Responsive improvements: stack headers on mobile, responsive padding, modal safe areas
- Add fade-in animations for stat cards and scale-in for modals
- 11 tests added for Layout component. All 430 tests pass.
This commit is contained in:
John Lightner 2026-04-07 13:56:05 -05:00
parent 5a1d029b9b
commit b5b85df2e5
13 changed files with 511 additions and 25 deletions

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -7,6 +7,22 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script>
// Detect system dark mode preference and apply before first paint
(function () {
var d = document.documentElement;
try {
var stored = localStorage.getItem("pl-theme");
if (stored === "light") { d.classList.remove("dark"); }
else if (stored === "dark") { d.classList.add("dark"); }
else if (!window.matchMedia("(prefers-color-scheme: dark)").matches) {
d.classList.remove("dark");
}
} catch (e) {
// localStorage may be unavailable — keep dark default
}
})();
</script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View file

@ -51,16 +51,20 @@ describe("App routing", () => {
}); });
it("renders DashboardPage at /", async () => { it("renders DashboardPage at /", async () => {
vi.spyOn(client.projects, "list").mockResolvedValue({ items: [], total: 0 });
vi.spyOn(client.experiments, "list").mockResolvedValue({ items: [], total: 0 });
vi.spyOn(client.admin, "getStats").mockResolvedValue({} as Record<string, unknown>);
renderWithRouter("/"); renderWithRouter("/");
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument(); // Page heading (h1) — not just the sidebar nav link
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
}); });
}); });
it("renders ProjectsPage at /projects", async () => { it("renders ProjectsPage at /projects", async () => {
renderWithRouter("/projects"); renderWithRouter("/projects");
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Projects")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Projects" })).toBeInTheDocument();
}); });
}); });
@ -96,14 +100,14 @@ describe("App routing", () => {
it("renders AdminPage at /admin", async () => { it("renders AdminPage at /admin", async () => {
renderWithRouter("/admin"); renderWithRouter("/admin");
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Admin")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Admin" })).toBeInTheDocument();
}); });
}); });
it("redirects unknown routes to dashboard", async () => { it("redirects unknown routes to dashboard", async () => {
renderWithRouter("/nonexistent"); renderWithRouter("/nonexistent");
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
}); });
}); });
}); });

View file

@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext"; import { AuthProvider } from "./contexts/AuthContext";
import Layout from "./components/Layout";
import SetupPage from "./pages/SetupPage"; import SetupPage from "./pages/SetupPage";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage"; import DashboardPage from "./pages/DashboardPage";
@ -9,12 +10,10 @@ import LivePage from "./pages/LivePage";
import ComparePage from "./pages/ComparePage"; import ComparePage from "./pages/ComparePage";
import AdminPage from "./pages/AdminPage"; import AdminPage from "./pages/AdminPage";
export default function App() { function AuthenticatedRoutes() {
return ( return (
<AuthProvider> <Layout>
<Routes> <Routes>
<Route path="/setup" element={<SetupPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/projects" element={<ProjectsPage />} />
<Route path="/experiments/:id" element={<ExperimentPage />} /> <Route path="/experiments/:id" element={<ExperimentPage />} />
@ -23,6 +22,18 @@ export default function App() {
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Layout>
);
}
export default function App() {
return (
<AuthProvider>
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/*" element={<AuthenticatedRoutes />} />
</Routes>
</AuthProvider> </AuthProvider>
); );
} }

View file

@ -0,0 +1,141 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { describe, it, expect, vi, beforeEach } from "vitest";
import Layout from "./Layout";
// ---------------------------------------------------------------------------
// Mock AuthContext
// ---------------------------------------------------------------------------
const mockLogout = vi.fn();
vi.mock("../contexts/AuthContext", () => ({
useAuth: () => ({
user: { id: "u1", username: "admin", role: "admin" },
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
logout: mockLogout,
}),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderLayout(route = "/") {
return render(
<MemoryRouter initialEntries={[route]}>
<Layout>
<div data-testid="page-content">Page Content</div>
</Layout>
</MemoryRouter>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Layout", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset dark mode class for consistent tests
document.documentElement.classList.add("dark");
try { localStorage.removeItem("pl-theme"); } catch { /* jsdom may not support */ }
});
it("renders sidebar with navigation links", () => {
renderLayout();
expect(screen.getAllByText("PromptLooper").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByText("Compare")).toBeInTheDocument();
expect(screen.getByText("Admin")).toBeInTheDocument();
});
it("renders children content", () => {
renderLayout();
expect(screen.getByTestId("page-content")).toBeInTheDocument();
expect(screen.getByText("Page Content")).toBeInTheDocument();
});
it("displays the current user username", () => {
renderLayout();
expect(screen.getByTestId("user-display")).toHaveTextContent("admin");
});
it("calls logout when sign out is clicked", async () => {
const user = userEvent.setup();
renderLayout();
await user.click(screen.getByTestId("logout-btn"));
expect(mockLogout).toHaveBeenCalledTimes(1);
});
it("toggles dark mode when theme toggle is clicked", async () => {
const user = userEvent.setup();
renderLayout();
const toggle = screen.getByTestId("theme-toggle");
// Initially dark (set in beforeEach)
expect(document.documentElement.classList.contains("dark")).toBe(true);
// Click to switch to light
await user.click(toggle);
expect(document.documentElement.classList.contains("dark")).toBe(false);
// Click to switch back to dark
await user.click(toggle);
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
it("highlights the active navigation link", () => {
renderLayout("/projects");
const projectsLink = screen.getByText("Projects").closest("a");
expect(projectsLink?.className).toContain("bg-indigo-100");
});
it("shows mobile menu button", () => {
renderLayout();
expect(screen.getByTestId("mobile-menu-btn")).toBeInTheDocument();
});
it("opens sidebar when mobile menu button is clicked", async () => {
const user = userEvent.setup();
renderLayout();
const sidebar = screen.getByTestId("sidebar");
// Initially hidden on mobile (has -translate-x-full)
expect(sidebar.className).toContain("-translate-x-full");
await user.click(screen.getByTestId("mobile-menu-btn"));
expect(sidebar.className).toContain("translate-x-0");
expect(sidebar.className).not.toContain("-translate-x-full");
});
it("closes sidebar when backdrop is clicked", async () => {
const user = userEvent.setup();
renderLayout();
// Open sidebar
await user.click(screen.getByTestId("mobile-menu-btn"));
expect(screen.getByTestId("sidebar-backdrop")).toBeInTheDocument();
// Close via backdrop
await user.click(screen.getByTestId("sidebar-backdrop"));
const sidebar = screen.getByTestId("sidebar");
expect(sidebar.className).toContain("-translate-x-full");
});
it("has accessible navigation landmark", () => {
renderLayout();
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument();
});
it("theme toggle has accessible label", () => {
renderLayout();
const toggle = screen.getByTestId("theme-toggle");
expect(toggle).toHaveAttribute("aria-label");
});
});

View file

@ -0,0 +1,223 @@
import { useState, useEffect, useCallback } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
// ---------------------------------------------------------------------------
// Dark mode helpers
// ---------------------------------------------------------------------------
function getInitialTheme(): "light" | "dark" {
try {
const stored = localStorage.getItem("pl-theme");
if (stored === "light" || stored === "dark") return stored;
} catch {
// ignore
}
return document.documentElement.classList.contains("dark") ? "dark" : "light";
}
function applyTheme(theme: "light" | "dark") {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
try {
localStorage.setItem("pl-theme", theme);
} catch {
// ignore
}
}
// ---------------------------------------------------------------------------
// Nav items
// ---------------------------------------------------------------------------
interface NavItem {
label: string;
to: string;
icon: React.ReactNode;
}
const NAV_ITEMS: NavItem[] = [
{
label: "Dashboard",
to: "/",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
),
},
{
label: "Projects",
to: "/projects",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
),
},
{
label: "Compare",
to: "/compare",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
),
},
{
label: "Admin",
to: "/admin",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
// ---------------------------------------------------------------------------
// Layout Component
// ---------------------------------------------------------------------------
export default function Layout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth();
const location = useLocation();
const [theme, setTheme] = useState<"light" | "dark">(getInitialTheme);
const [sidebarOpen, setSidebarOpen] = useState(false);
const toggleTheme = useCallback(() => {
setTheme((prev) => {
const next = prev === "dark" ? "light" : "dark";
applyTheme(next);
return next;
});
}, []);
// Close mobile sidebar on navigation
useEffect(() => {
setSidebarOpen(false);
}, [location.pathname]);
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? "bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300"
: "text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-white"
}`;
return (
<div className="flex h-screen overflow-hidden bg-slate-50 dark:bg-slate-900">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 z-30 bg-black/40 md:hidden"
onClick={() => setSidebarOpen(false)}
data-testid="sidebar-backdrop"
/>
)}
{/* Sidebar */}
<aside
data-testid="sidebar"
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 transition-transform duration-300 md:static md:translate-x-0 ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
{/* Logo */}
<div className="flex h-16 items-center gap-3 border-b border-slate-200 dark:border-slate-700 px-5">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-600 text-white text-sm font-bold shadow-sm">
PL
</div>
<span className="text-lg font-bold text-slate-900 dark:text-white">
PromptLooper
</span>
</div>
{/* Nav links */}
<nav className="flex-1 overflow-y-auto px-3 py-4 custom-scrollbar" aria-label="Main navigation">
<ul className="space-y-1">
{NAV_ITEMS.map((item) => (
<li key={item.to}>
<NavLink to={item.to} end={item.to === "/"} className={navLinkClass}>
{item.icon}
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
{/* Bottom section */}
<div className="border-t border-slate-200 dark:border-slate-700 p-3 space-y-2">
{/* Dark mode toggle */}
<button
type="button"
onClick={toggleTheme}
data-testid="theme-toggle"
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
) : (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
)}
{theme === "dark" ? "Light Mode" : "Dark Mode"}
</button>
{/* User / Logout */}
{user && (
<div className="flex items-center justify-between rounded-lg bg-slate-50 dark:bg-slate-700/50 px-3 py-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 truncate" data-testid="user-display">
{user.username}
</span>
<button
type="button"
onClick={logout}
data-testid="logout-btn"
className="text-xs font-medium text-slate-500 dark:text-slate-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
>
Sign out
</button>
</div>
)}
</div>
</aside>
{/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Top bar (mobile) */}
<header className="flex h-14 items-center gap-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-4 md:hidden">
<button
type="button"
onClick={() => setSidebarOpen(true)}
data-testid="mobile-menu-btn"
className="rounded-lg p-2 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
aria-label="Open menu"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<span className="text-lg font-bold text-slate-900 dark:text-white">
PromptLooper
</span>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto custom-scrollbar">
{children}
</main>
</div>
</div>
);
}

View file

@ -1,3 +1,73 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
/* Smooth color transitions when toggling dark mode */
html {
color-scheme: light;
scroll-behavior: smooth;
}
html.dark {
color-scheme: dark;
}
body {
@apply bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-100 antialiased;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Consistent focus-visible ring for keyboard navigation */
*:focus-visible {
@apply outline-none ring-2 ring-indigo-500/50 ring-offset-2 ring-offset-white dark:ring-offset-slate-900;
}
/* Remove default focus outline — we use focus-visible above */
*:focus:not(:focus-visible) {
outline: none;
}
}
@layer components {
/* Smooth state transitions for interactive elements */
button,
a,
input,
select,
textarea {
@apply transition-colors duration-150;
}
/* Custom scrollbar for dark mode */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-slate-300 dark:bg-slate-600 rounded-full;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
@apply bg-slate-400 dark:bg-slate-500;
}
}
@layer utilities {
/* Animation delay utilities for staggered entrance */
.animation-delay-100 {
animation-delay: 100ms;
}
.animation-delay-200 {
animation-delay: 200ms;
}
.animation-delay-300 {
animation-delay: 300ms;
}
}

View file

@ -656,7 +656,7 @@ export default function AdminPage() {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-8"> <div className="min-h-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">

View file

@ -603,7 +603,7 @@ export default function ComparePage() {
} }
return ( return (
<div className="p-6 max-w-7xl mx-auto space-y-6"> <div className="min-h-full px-4 py-6 sm:px-6 lg:px-8 max-w-7xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"> <h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Compare Runs Compare Runs
</h1> </h1>

View file

@ -68,7 +68,7 @@ function StatCard({
testId: string; testId: string;
}) { }) {
return ( return (
<div className="rounded-xl bg-white dark:bg-slate-800 p-5 shadow-md ring-1 ring-slate-200 dark:ring-slate-700"> <div className="rounded-xl bg-white dark:bg-slate-800 p-5 shadow-md ring-1 ring-slate-200 dark:ring-slate-700 animate-fade-in">
<p className="text-sm text-slate-500 dark:text-slate-400">{label}</p> <p className="text-sm text-slate-500 dark:text-slate-400">{label}</p>
<p <p
className="mt-1 text-2xl font-bold text-slate-900 dark:text-white" className="mt-1 text-2xl font-bold text-slate-900 dark:text-white"
@ -238,10 +238,10 @@ export default function DashboardPage() {
}, [loadData]); }, [loadData]);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-8"> <div className="min-h-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
{/* Header */} {/* Header */}
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-white"> <h1 className="text-3xl font-bold text-slate-900 dark:text-white">
Dashboard Dashboard

View file

@ -939,7 +939,7 @@ export default function ExperimentPage() {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-8"> <div className="min-h-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<p className="text-center text-slate-500 dark:text-slate-400 animate-pulse py-16"> <p className="text-center text-slate-500 dark:text-slate-400 animate-pulse py-16">
Loading experiment Loading experiment
@ -951,7 +951,7 @@ export default function ExperimentPage() {
if (error) { if (error) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-8"> <div className="min-h-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div <div
role="alert" role="alert"
@ -972,7 +972,7 @@ export default function ExperimentPage() {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-8"> <div className="min-h-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View file

@ -273,7 +273,7 @@ export default function LivePage() {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 flex items-center justify-center"> <div className="min-h-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 flex items-center justify-center">
<p className="text-slate-500 dark:text-slate-400 animate-pulse"> <p className="text-slate-500 dark:text-slate-400 animate-pulse">
Loading experiment Loading experiment
</p> </p>
@ -283,7 +283,7 @@ export default function LivePage() {
if (error) { if (error) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-8"> <div className="min-h-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-2xl">
<div <div
role="alert" role="alert"
@ -304,7 +304,7 @@ export default function LivePage() {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-6"> <div className="min-h-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
{/* Header */} {/* Header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-4"> <div className="mb-6 flex flex-wrap items-center justify-between gap-4">

View file

@ -68,10 +68,10 @@ function NewProjectModal({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
data-testid="new-project-modal" data-testid="new-project-modal"
> >
<div className="w-full max-w-md rounded-xl bg-white dark:bg-slate-800 p-6 shadow-2xl ring-1 ring-slate-200 dark:ring-slate-700"> <div className="w-full max-w-md rounded-xl bg-white dark:bg-slate-800 p-6 shadow-2xl ring-1 ring-slate-200 dark:ring-slate-700 animate-scale-in">
<h2 className="mb-4 text-xl font-bold text-slate-900 dark:text-white"> <h2 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">
New Project New Project
</h2> </h2>
@ -258,10 +258,10 @@ export default function ProjectsPage() {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-8"> <div className="min-h-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
{/* Header */} {/* Header */}
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-white"> <h1 className="text-3xl font-bold text-slate-900 dark:text-white">
Projects Projects

View file

@ -1,8 +1,29 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: { theme: {
extend: {}, extend: {
keyframes: {
"fade-in": {
from: { opacity: "0", transform: "translateY(4px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
"slide-in": {
from: { opacity: "0", transform: "translateX(-8px)" },
to: { opacity: "1", transform: "translateX(0)" },
},
"scale-in": {
from: { opacity: "0", transform: "scale(0.95)" },
to: { opacity: "1", transform: "scale(1)" },
},
},
animation: {
"fade-in": "fade-in 0.3s ease-out",
"slide-in": "slide-in 0.3s ease-out",
"scale-in": "scale-in 0.2s ease-out",
},
},
}, },
plugins: [], plugins: [],
}; };