Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 7 additions & 29 deletions apps/loopover-miner-ui/src/components/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,17 @@
import { useState } from "react";
import { Button } from "@loopover/ui-kit/components/button";

// Light/dark theme toggle for miner-ui (#6508 / #6828). The shared @loopover/ui-kit theme.css already ships BOTH
// Light/dark theme toggle for miner-ui (#6508). 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 <html> — 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. Compact ghost icon so the header row stays usable
// beside the four nav links on narrow widths.
// persisted value to restore the theme before first paint.
const STORAGE_KEY = "loopover.miner_theme";

function SunIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="size-4">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
);
}

function MoonIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="size-4">
<path d="M21 14.5A8.5 8.5 0 1 1 9.5 3 7 7 0 0 0 21 14.5z" />
</svg>
);
}

export function ThemeToggle() {
// Client-only Vite SPA — document is always present when this runs.
const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains("dark"));
const [isDark, setIsDark] = useState(() =>
typeof document === "undefined" ? true : document.documentElement.classList.contains("dark"),
);

function toggle() {
const nextIsDark = !isDark;
Expand All @@ -44,13 +27,8 @@ export function ThemeToggle() {
}

return (
<Button
variant="ghost"
size="icon"
onClick={toggle}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? <SunIcon /> : <MoonIcon />}
<Button variant="outline" size="sm" onClick={toggle}>
{isDark ? "Switch to light mode" : "Switch to dark mode"}
</Button>
);
}
149 changes: 0 additions & 149 deletions apps/loopover-miner-ui/src/root-shell-nav.test.tsx

This file was deleted.

75 changes: 34 additions & 41 deletions apps/loopover-miner-ui/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,62 +16,55 @@ 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 `<Outlet/>` 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 (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-40 border-b-hairline bg-background/85 backdrop-blur">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-4 gap-y-3 px-4 py-3 sm:px-6">
<div className="min-w-0 flex-1">
<header className="border-b-hairline px-6 py-4">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4">
<div>
<p className="text-token-xs uppercase tracking-[0.2em] text-primary font-mono">LoopOver Miner</p>
<h1 className="text-token-lg font-display font-semibold">Local dashboard</h1>
</div>
<nav
aria-label="Primary"
className="flex max-w-full flex-1 basis-full items-center gap-3 overflow-x-auto text-muted-foreground sm:basis-auto sm:justify-center md:gap-4"
>
{NAV_ITEMS.map((item) => (
<Link
key={item.to}
to={item.to}
{...("exact" in item && item.exact ? { activeOptions: { exact: true } } : {})}
className={NAV_LINK_CLASS}
activeProps={{
className: "text-foreground font-medium after:scale-x-100 after:bg-mint",
"aria-current": "page",
}}
inactiveProps={{
className: "text-muted-foreground hover:after:scale-x-100 hover:after:bg-foreground/40",
}}
>
{item.label}
</Link>
))}
<nav className="flex gap-4 text-token-sm text-muted-foreground">
<Link
to="/"
activeOptions={{ exact: true }}
className="hover-surface rounded-token-sm px-2 py-1 hover:text-foreground"
activeProps={{ className: "text-primary font-medium", "aria-current": "page" }}
>
Overview
</Link>
<Link
to="/run-history"
className="hover-surface rounded-token-sm px-2 py-1 hover:text-foreground"
activeProps={{ className: "text-primary font-medium", "aria-current": "page" }}
>
Run history
</Link>
<Link
to="/portfolio"
className="hover-surface rounded-token-sm px-2 py-1 hover:text-foreground"
activeProps={{ className: "text-primary font-medium", "aria-current": "page" }}
>
Portfolio
</Link>
<Link
to="/ledgers"
className="hover-surface rounded-token-sm px-2 py-1 hover:text-foreground"
activeProps={{ className: "text-primary font-medium", "aria-current": "page" }}
>
Ledgers
</Link>
</nav>
<div className="ml-auto shrink-0 sm:ml-0">
<ThemeToggle />
</div>
<ThemeToggle />
</div>
</header>
{/* Row: routed content + the persistent rail docked beside it (never overlapping) on wide viewports. */}
Expand Down
16 changes: 2 additions & 14 deletions apps/loopover-miner-ui/src/theme-toggle.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ThemeToggle } from "./components/theme-toggle";

describe("ThemeToggle (#6508)", () => {
Expand All @@ -15,7 +15,7 @@ describe("ThemeToggle (#6508)", () => {
localStorage.clear();
});

it("labels the compact icon button to switch AWAY from the current theme (dark light)", () => {
it("labels the button to switch AWAY from the current theme (dark shows 'Switch to light mode')", () => {
render(<ThemeToggle />);
expect(screen.getByRole("button", { name: "Switch to light mode" })).toBeTruthy();
});
Expand All @@ -41,16 +41,4 @@ 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(<ThemeToggle />);
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import {
AlertTriangle,
Bot,
Check,
CheckCircle2,
CircleSlash,
Copy,
ListChecks,
Play,
RefreshCw,
ShieldCheck,
UserCheck,
} from "lucide-react";
import { toast } from "sonner";

import {
DiffBlock,
Expand Down Expand Up @@ -718,19 +715,6 @@ export function PreviewResult({
error: string | null;
busy: boolean;
}) {
const [copied, setCopied] = useState(false);

const onCopyComment = async (previewComment: string) => {
try {
await navigator.clipboard.writeText(previewComment);
setCopied(true);
toast.success("Comment preview copied", { description: "Paste it wherever you need it." });
setTimeout(() => setCopied(false), 1400);
} catch {
toast.error("Copy failed", { description: "Select the preview and copy manually." });
}
};

if (error) {
return (
<div className="rounded-token border border-danger/30 bg-danger/[0.04] p-4 text-token-sm text-danger">
Expand Down Expand Up @@ -840,20 +824,7 @@ export function PreviewResult({
<div className="font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
Public comment preview
</div>
<div className="flex items-center gap-2">
{preview.previewComment ? (
<button
type="button"
onClick={() => onCopyComment(preview.previewComment!)}
aria-label={copied ? "Comment preview copied" : "Copy comment preview"}
className="inline-flex h-6 items-center gap-1.5 rounded-token border border-border bg-transparent px-2 text-token-2xs text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground focus-ring motion-reduce:transition-none"
>
{copied ? <Check className="size-3 text-mint" /> : <Copy className="size-3" />}
{copied ? "Copied" : "Copy"}
</button>
) : null}
<StatusPill status="info">sanitized</StatusPill>
</div>
<StatusPill status="info">sanitized</StatusPill>
</div>
<pre className="max-h-[360px] overflow-auto whitespace-pre-wrap rounded-token border border-border bg-[oklch(0.13_0.005_260)] p-3 font-mono text-token-xs leading-token-relaxed text-foreground/90">
{preview.previewComment ?? "No public comment would be posted for this scenario."}
Expand Down
Loading
Loading