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:
parent
5a1d029b9b
commit
b5b85df2e5
13 changed files with 511 additions and 25 deletions
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
|
@ -7,6 +7,22 @@
|
|||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -51,16 +51,20 @@ describe("App routing", () => {
|
|||
});
|
||||
|
||||
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("/");
|
||||
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 () => {
|
||||
renderWithRouter("/projects");
|
||||
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 () => {
|
||||
renderWithRouter("/admin");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Admin")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Admin" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects unknown routes to dashboard", async () => {
|
||||
renderWithRouter("/nonexistent");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import Layout from "./components/Layout";
|
||||
import SetupPage from "./pages/SetupPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
|
|
@ -9,12 +10,10 @@ import LivePage from "./pages/LivePage";
|
|||
import ComparePage from "./pages/ComparePage";
|
||||
import AdminPage from "./pages/AdminPage";
|
||||
|
||||
export default function App() {
|
||||
function AuthenticatedRoutes() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/experiments/:id" element={<ExperimentPage />} />
|
||||
|
|
@ -23,6 +22,18 @@ export default function App() {
|
|||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
141
frontend/src/components/Layout.test.tsx
Normal file
141
frontend/src/components/Layout.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
223
frontend/src/components/Layout.tsx
Normal file
223
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,73 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -656,7 +656,7 @@ export default function AdminPage() {
|
|||
}
|
||||
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
|
|
|
|||
|
|
@ -603,7 +603,7 @@ export default function ComparePage() {
|
|||
}
|
||||
|
||||
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">
|
||||
Compare Runs
|
||||
</h1>
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ function StatCard({
|
|||
testId: string;
|
||||
}) {
|
||||
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="mt-1 text-2xl font-bold text-slate-900 dark:text-white"
|
||||
|
|
@ -238,10 +238,10 @@ export default function DashboardPage() {
|
|||
}, [loadData]);
|
||||
|
||||
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">
|
||||
{/* 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>
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
Dashboard
|
||||
|
|
|
|||
|
|
@ -939,7 +939,7 @@ export default function ExperimentPage() {
|
|||
|
||||
if (loading) {
|
||||
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">
|
||||
<p className="text-center text-slate-500 dark:text-slate-400 animate-pulse py-16">
|
||||
Loading experiment…
|
||||
|
|
@ -951,7 +951,7 @@ export default function ExperimentPage() {
|
|||
|
||||
if (error) {
|
||||
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
|
||||
role="alert"
|
||||
|
|
@ -972,7 +972,7 @@ export default function ExperimentPage() {
|
|||
}
|
||||
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ export default function LivePage() {
|
|||
|
||||
if (loading) {
|
||||
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">
|
||||
Loading experiment…
|
||||
</p>
|
||||
|
|
@ -283,7 +283,7 @@ export default function LivePage() {
|
|||
|
||||
if (error) {
|
||||
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
|
||||
role="alert"
|
||||
|
|
@ -304,7 +304,7 @@ export default function LivePage() {
|
|||
}
|
||||
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
|
|
|
|||
|
|
@ -68,10 +68,10 @@ function NewProjectModal({
|
|||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
New Project
|
||||
</h2>
|
||||
|
|
@ -258,10 +258,10 @@ export default function ProjectsPage() {
|
|||
}
|
||||
|
||||
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">
|
||||
{/* 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>
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
Projects
|
||||
|
|
|
|||
|
|
@ -1,8 +1,29 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
darkMode: "class",
|
||||
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: [],
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue