diff --git a/apps/loopover-miner-ui/src/components/theme-toggle.tsx b/apps/loopover-miner-ui/src/components/theme-toggle.tsx index 041c1b4828..27002d189a 100644 --- a/apps/loopover-miner-ui/src/components/theme-toggle.tsx +++ b/apps/loopover-miner-ui/src/components/theme-toggle.tsx @@ -1,17 +1,34 @@ import { useState } from "react"; import { Button } from "@loopover/ui-kit/components/button"; -// Light/dark theme toggle for miner-ui (#6508). The shared @loopover/ui-kit theme.css already ships BOTH +// Light/dark theme toggle for miner-ui (#6508 / #6828). The shared @loopover/ui-kit theme.css already ships BOTH // palettes (light tokens under :root, dark overrides under .dark), switched purely by whether a `.dark` class // is present on — so this control only flips that class, mirrors it into colorScheme (so native form // controls follow the theme), and persists the choice. index.html's inline no-flash script reads the same -// persisted value to restore the theme before first paint. +// persisted value to restore the theme before first paint. Compact ghost icon so the header row stays usable +// beside the four nav links on narrow widths. const STORAGE_KEY = "loopover.miner_theme"; -export function ThemeToggle() { - const [isDark, setIsDark] = useState(() => - typeof document === "undefined" ? true : document.documentElement.classList.contains("dark"), +function SunIcon() { + return ( + ); +} + +function MoonIcon() { + return ( + + ); +} + +export function ThemeToggle() { + // Client-only Vite SPA — document is always present when this runs. + const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains("dark")); function toggle() { const nextIsDark = !isDark; @@ -27,8 +44,13 @@ export function ThemeToggle() { } return ( - ); } diff --git a/apps/loopover-miner-ui/src/root-shell-nav.test.tsx b/apps/loopover-miner-ui/src/root-shell-nav.test.tsx new file mode 100644 index 0000000000..3fc7922a4b --- /dev/null +++ b/apps/loopover-miner-ui/src/root-shell-nav.test.tsx @@ -0,0 +1,149 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Nav active-route regression (#6828). Stub Link like chat-rail.test.tsx, but honor activeProps / + * inactiveProps from a controllable mock path so we can assert aria-current + mint-underline classes + * without standing up a full RouterProvider. + */ +let mockPath = "/"; + +vi.mock("@tanstack/react-router", async () => { + const react = await import("react"); + return { + createRootRoute: (options: unknown) => ({ options }), + Outlet: () => null, + Link: ({ + children, + to, + className, + activeProps, + inactiveProps, + activeOptions: _activeOptions, + ...rest + }: { + children?: React.ReactNode; + to?: unknown; + className?: string; + activeProps?: Record; + inactiveProps?: Record; + activeOptions?: { exact?: boolean }; + }) => { + const href = typeof to === "string" ? to : "#"; + const exact = _activeOptions?.exact === true; + const isActive = exact ? mockPath === href : mockPath === href || mockPath.startsWith(`${href}/`); + const stateProps = (isActive ? activeProps : inactiveProps) ?? {}; + const { className: stateClass, ...stateRest } = stateProps as { + className?: string; + [key: string]: unknown; + }; + const mergedClass = [className, stateClass].filter(Boolean).join(" "); + return react.createElement("a", { href, className: mergedClass || undefined, ...stateRest, ...rest }, children); + }, + }; +}); + +import { RootShell } from "./routes/__root"; + +const originalInnerWidth = window.innerWidth; + +function setViewport(width: number) { + Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: width }); + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: width < 768, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +beforeEach(() => { + mockPath = "/"; + setViewport(1200); + document.documentElement.classList.add("dark"); +}); + +afterEach(() => { + Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: originalInnerWidth }); + vi.unstubAllGlobals(); + document.documentElement.classList.remove("dark"); +}); + +describe("RootShell header nav (#6828)", () => { + it("marks exactly the Overview link as current on `/` (exact match)", () => { + mockPath = "/"; + render( + +
page
+
, + ); + + const current = screen.getAllByRole("link").filter((el) => el.getAttribute("aria-current") === "page"); + expect(current).toHaveLength(1); + expect(current[0].textContent).toBe("Overview"); + expect(current[0].className).toMatch(/after:bg-mint/); + expect(screen.getByRole("link", { name: "Portfolio" }).getAttribute("aria-current")).toBeNull(); + }); + + it("does not keep Overview current on a nested path (exact activeOptions)", () => { + mockPath = "/portfolio"; + render( + +
page
+
, + ); + + expect(screen.getByRole("link", { name: "Overview" }).getAttribute("aria-current")).toBeNull(); + const portfolio = screen.getByRole("link", { name: "Portfolio" }); + expect(portfolio.getAttribute("aria-current")).toBe("page"); + expect(portfolio.className).toMatch(/after:bg-mint/); + }); + + it("highlights Run history and Ledgers on their own routes", () => { + mockPath = "/run-history"; + const { rerender } = render( + +
page
+
, + ); + expect(screen.getByRole("link", { name: "Run history" }).getAttribute("aria-current")).toBe("page"); + + mockPath = "/ledgers"; + rerender( + +
page
+
, + ); + expect(screen.getByRole("link", { name: "Ledgers" }).getAttribute("aria-current")).toBe("page"); + expect(screen.getByRole("link", { name: "Run history" }).getAttribute("aria-current")).toBeNull(); + }); + + it("exposes a Primary nav landmark and sticky header chrome classes", () => { + render( + +
page
+
, + ); + + expect(screen.getByRole("navigation", { name: "Primary" })).toBeTruthy(); + const header = document.querySelector("header"); + expect(header?.className).toMatch(/sticky/); + expect(header?.className).toMatch(/backdrop-blur/); + }); + + it("renders the compact theme toggle with the dark→light accessible name", () => { + render( + +
page
+
, + ); + expect(screen.getByRole("button", { name: "Switch to light mode" })).toBeTruthy(); + }); +}); diff --git a/apps/loopover-miner-ui/src/routes/__root.tsx b/apps/loopover-miner-ui/src/routes/__root.tsx index cf2fc5ee43..9323d81ac5 100644 --- a/apps/loopover-miner-ui/src/routes/__root.tsx +++ b/apps/loopover-miner-ui/src/routes/__root.tsx @@ -16,55 +16,62 @@ function RootComponent() { ); } +const NAV_ITEMS = [ + { to: "/", label: "Overview", exact: true }, + { to: "/run-history", label: "Run history" }, + { to: "/portfolio", label: "Portfolio" }, + { to: "/ledgers", label: "Ledgers" }, +] as const; + +/** Shared nav-link chrome (#6828) — mint underline active cue mirrors loopover-ui's site-header. */ +const NAV_LINK_CLASS = + "relative shrink-0 px-1 py-1 text-token-sm transition-colors duration-150 motion-reduce:transition-none hover:text-foreground after:content-[''] after:absolute after:left-0 after:right-0 after:-bottom-1 after:h-[2px] after:rounded-full after:bg-transparent after:scale-x-0 after:origin-left after:transition-transform after:duration-200 motion-reduce:after:transition-none focus-ring rounded-token-sm"; + /** * The persistent app shell (#6513). Exported for unit testing. It owns the chat-rail open/collapsed state, and * because it's rendered by the root route, TanStack Router keeps it — and that state — mounted across * client-side navigation between the four routes, so the rail never resets on a route change. The routed page * is `children` (the `` content), which is what swaps on navigation while this shell stays mounted. + * + * Header chrome (#6828): sticky translucent bar + mint-underline active routes (site-header language), without + * adopting sidebar.tsx as primary nav (reserved for the chat rail's mobile sheet). */ export function RootShell({ children }: { children: React.ReactNode }) { const [railOpen, setRailOpen] = React.useState(false); return (
-
-
-
+
+
+

LoopOver Miner

Local dashboard

-
{/* Row: routed content + the persistent rail docked beside it (never overlapping) on wide viewports. */} diff --git a/apps/loopover-miner-ui/src/theme-toggle.test.tsx b/apps/loopover-miner-ui/src/theme-toggle.test.tsx index 13e4240f51..7543fbcd7c 100644 --- a/apps/loopover-miner-ui/src/theme-toggle.test.tsx +++ b/apps/loopover-miner-ui/src/theme-toggle.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ThemeToggle } from "./components/theme-toggle"; describe("ThemeToggle (#6508)", () => { @@ -15,7 +15,7 @@ describe("ThemeToggle (#6508)", () => { localStorage.clear(); }); - it("labels the button to switch AWAY from the current theme (dark shows 'Switch to light mode')", () => { + it("labels the compact icon button to switch AWAY from the current theme (dark → light)", () => { render(); expect(screen.getByRole("button", { name: "Switch to light mode" })).toBeTruthy(); }); @@ -41,4 +41,16 @@ describe("ThemeToggle (#6508)", () => { expect(localStorage.getItem("loopover.miner_theme")).toBe("dark"); expect(screen.getByRole("button", { name: "Switch to light mode" })).toBeTruthy(); }); + + it("survives a localStorage.setItem throw (private mode) and still flips the in-page theme", () => { + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota"); + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Switch to light mode" })); + expect(document.documentElement.classList.contains("dark")).toBe(false); + expect(document.documentElement.style.colorScheme).toBe("light"); + expect(screen.getByRole("button", { name: "Switch to dark mode" })).toBeTruthy(); + setItem.mockRestore(); + }); });