From b5b85df2e597556a1797abf3eadcbbb2e1358038 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 13:56:05 -0500 Subject: [PATCH] =?UTF-8?q?MAESTRO:=20Style=20pass=20=E2=80=94=20dark=20mo?= =?UTF-8?q?de,=20shared=20Layout=20with=20nav=20sidebar,=20responsive=20im?= =?UTF-8?q?provements,=20animations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- frontend/index.html | 18 +- frontend/src/App.test.tsx | 12 +- frontend/src/App.tsx | 19 +- frontend/src/components/Layout.test.tsx | 141 +++++++++++++++ frontend/src/components/Layout.tsx | 223 ++++++++++++++++++++++++ frontend/src/index.css | 70 ++++++++ frontend/src/pages/AdminPage.tsx | 2 +- frontend/src/pages/ComparePage.tsx | 2 +- frontend/src/pages/DashboardPage.tsx | 6 +- frontend/src/pages/ExperimentPage.tsx | 6 +- frontend/src/pages/LivePage.tsx | 6 +- frontend/src/pages/ProjectsPage.tsx | 8 +- frontend/tailwind.config.js | 23 ++- 13 files changed, 511 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/Layout.test.tsx create mode 100644 frontend/src/components/Layout.tsx diff --git a/frontend/index.html b/frontend/index.html index 4e22599..7d67ac7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + @@ -7,6 +7,22 @@
+ diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 169abe2..4fb88ab 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -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); 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(); }); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index afd2aa0..3eb7eba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - + - } /> - } /> } /> } /> } /> @@ -23,6 +22,18 @@ export default function App() { } /> } /> + + ); +} + +export default function App() { + return ( + + + } /> + } /> + } /> + ); } diff --git a/frontend/src/components/Layout.test.tsx b/frontend/src/components/Layout.test.tsx new file mode 100644 index 0000000..7e62386 --- /dev/null +++ b/frontend/src/components/Layout.test.tsx @@ -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( + + +
Page Content
+
+
, + ); +} + +// --------------------------------------------------------------------------- +// 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"); + }); +}); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..d9c961e --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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: ( + + + + ), + }, + { + label: "Projects", + to: "/projects", + icon: ( + + + + ), + }, + { + label: "Compare", + to: "/compare", + icon: ( + + + + ), + }, + { + label: "Admin", + to: "/admin", + icon: ( + + + + + ), + }, +]; + +// --------------------------------------------------------------------------- +// 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 ( +
+ {/* Mobile sidebar backdrop */} + {sidebarOpen && ( +
setSidebarOpen(false)} + data-testid="sidebar-backdrop" + /> + )} + + {/* Sidebar */} + + + {/* Main content area */} +
+ {/* Top bar (mobile) */} +
+ + + PromptLooper + +
+ + {/* Page content */} +
+ {children} +
+
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index b5c61c9..b1dd5c7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 056e481..5c25ac9 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -656,7 +656,7 @@ export default function AdminPage() { } return ( -
+
{/* Header */}
diff --git a/frontend/src/pages/ComparePage.tsx b/frontend/src/pages/ComparePage.tsx index 85ae280..8686c34 100644 --- a/frontend/src/pages/ComparePage.tsx +++ b/frontend/src/pages/ComparePage.tsx @@ -603,7 +603,7 @@ export default function ComparePage() { } return ( -
+

Compare Runs

diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 0457f0f..b7c7912 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -68,7 +68,7 @@ function StatCard({ testId: string; }) { return ( -
+

{label}

+

{/* Header */} -
+

Dashboard diff --git a/frontend/src/pages/ExperimentPage.tsx b/frontend/src/pages/ExperimentPage.tsx index e4dd32d..c9d9bdc 100644 --- a/frontend/src/pages/ExperimentPage.tsx +++ b/frontend/src/pages/ExperimentPage.tsx @@ -939,7 +939,7 @@ export default function ExperimentPage() { if (loading) { return ( -
+

Loading experiment… @@ -951,7 +951,7 @@ export default function ExperimentPage() { if (error) { return ( -

+
+
{/* Header */}
diff --git a/frontend/src/pages/LivePage.tsx b/frontend/src/pages/LivePage.tsx index 2cda37e..a6985f2 100644 --- a/frontend/src/pages/LivePage.tsx +++ b/frontend/src/pages/LivePage.tsx @@ -273,7 +273,7 @@ export default function LivePage() { if (loading) { return ( -
+

Loading experiment…

@@ -283,7 +283,7 @@ export default function LivePage() { if (error) { return ( -
+
+
{/* Header */}
diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx index 97dd2e0..f95d85e 100644 --- a/frontend/src/pages/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage.tsx @@ -68,10 +68,10 @@ function NewProjectModal({ return (
-
+

New Project

@@ -258,10 +258,10 @@ export default function ProjectsPage() { } return ( -
+
{/* Header */} -
+

Projects diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 614c86b..4386682 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -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: [], };