## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Operators sign in via the `/auth` page, which renders before any session exists > - The signed-in app has a theme toggle inside `SidebarAccountMenu`, but the signed-out `/auth` page has none — first-time visitors are stuck in whichever theme was hardcoded at boot > - Master's existing toggle was inline inside `SidebarAccountMenu.tsx` as a `MenuAction` row, not exported as a reusable widget; round-1 of this PR added a standalone `ThemeToggle` but punted on unifying the two surfaces > - This pull request makes `ThemeToggle` the canonical theme widget (one source of truth for label, icon, and toggle behaviour), used both as a compact icon button on `/auth` and as a full-width menu row in `SidebarAccountMenu` > - The benefit is a working pre-auth theme switch and zero risk of the two call sites drifting out of sync as the theme model evolves ## Linked Issues or Issue Description No existing issue covers this directly — problem described in-PR (feature-gap shape): - The signed-in app has a theme toggle inside `SidebarAccountMenu`, but the signed-out `/auth` page has none — first-time visitors are stuck in whichever theme was hardcoded at boot. - The existing toggle lived inline in `SidebarAccountMenu.tsx` as a `MenuAction` row, not exported as a reusable widget, so the two surfaces could drift apart as the theme model evolves. - This PR supersedes part of PR #3732 (the auth-page toggle slice); no standalone issue was filed for it. Duplicate-PR search: related open theme PRs #2769 and #4666 add in-app three-state system-theme toggles — different surface from this PR (unauthenticated auth page); sibling PR in this series: #5873. ## What Changed - **`ui/src/components/ThemeToggle.tsx`** — accepts `variant: "icon" | "menu-action"` (default `"icon"`) and an `onAfterToggle` callback. Both variants share `useTheme` and the same label/icon derivation. The `menu-action` variant matches the existing `MenuAction` row styling. - **`ui/src/components/SidebarAccountMenu.tsx`** — drops its inline `useTheme()` + `MenuAction`-for-the-theme-row in favor of `<ThemeToggle variant="menu-action" onAfterToggle={() => setOpen(false)} />`. Sun/Moon icon imports and theme state move with it. - **`ui/src/pages/Auth.tsx`** — unchanged from round 0; renders `<ThemeToggle />` at top-right of the `/auth` page (already using the default `icon` variant). - **`ui/src/components/ThemeToggle.test.tsx`** (new) — covers both variants, the `onAfterToggle` callback, and the label/icon flip across themes. - **`ui/src/components/SidebarAccountMenu.test.tsx`** — unchanged; its `ThemeContext` mock still works because `ThemeToggle` uses the same hook. ## Verification - `pnpm --filter @paperclipai/ui run typecheck` — clean. - `npx vitest run src/components/ThemeToggle.test.tsx src/components/SidebarAccountMenu.test.tsx` — 5 passed (4 new + 1 existing). - Manual: launched `pnpm dev` for the UI, mocked `/api/auth/get-session` 401, navigated to `/auth` — toggle is visible top-right, click flips light/dark. Screenshots committed. ## Risks Low. The change is structural — both call sites render the same widget that already worked on each surface independently. `SidebarAccountMenu`'s popover behaviour is preserved via `onAfterToggle`, and `ThemeContext` is untouched. ## Model Used Claude Opus 4.7 (1M context), extended thinking mode. ## Checklist - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] Thinking path traces from project context to this change - [x] Model used specified - [x] Checked ROADMAP.md — not in conflict with planned core work - [x] Tests run locally and pass - [x] Added tests for the new ThemeToggle component (both variants) - [x] UI change — before/after screenshots in `docs/pr-screenshots/pr-5874/` - [x] No documentation updates required (purely internal refactor + new component) - [x] Documented risks above - [x] Will address all Greptile and reviewer comments before merge Supersedes part of #3732. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Devin Foley <devin@paperclip.ing>
This commit is contained in:
committed by
GitHub
parent
362c30ccdc
commit
8ddd735a7a
@@ -5,9 +5,7 @@ import {
|
||||
LogOut,
|
||||
Megaphone,
|
||||
type LucideIcon,
|
||||
Moon,
|
||||
UserRound,
|
||||
Sun,
|
||||
UserRoundPen,
|
||||
} from "lucide-react";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
@@ -15,10 +13,10 @@ import { Link } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
|
||||
const DOCS_URL = "https://docs.paperclip.ing/";
|
||||
@@ -111,7 +109,6 @@ export function SidebarAccountMenu({
|
||||
const queryClient = useQueryClient();
|
||||
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = onOpenChange ?? setInternalOpen;
|
||||
const { data: session } = useQuery({
|
||||
@@ -216,15 +213,7 @@ export function SidebarAccountMenu({
|
||||
external
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<MenuAction
|
||||
label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
description="Toggle the app appearance."
|
||||
icon={theme === "dark" ? Sun : Moon}
|
||||
onClick={() => {
|
||||
toggleTheme();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
<ThemeToggle variant="menu-action" onAfterToggle={() => setOpen(false)} />
|
||||
{deploymentMode === "authenticated" ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
const mockToggleTheme = vi.hoisted(() => vi.fn());
|
||||
const mockTheme = vi.hoisted(() => ({ value: "dark" as "dark" | "light" }));
|
||||
|
||||
vi.mock("../context/ThemeContext", () => ({
|
||||
useTheme: () => ({
|
||||
theme: mockTheme.value,
|
||||
toggleTheme: mockToggleTheme,
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("ThemeToggle", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockTheme.value = "dark";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders an icon button by default with the 'switch to light' label when current theme is dark", async () => {
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<ThemeToggle />);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.getAttribute("aria-label")).toBe("Switch to light mode");
|
||||
expect(button?.getAttribute("title")).toBe("Switch to light mode");
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("renders a menu-action row when variant='menu-action' and includes the description text", async () => {
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<ThemeToggle variant="menu-action" />);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Switch to light mode");
|
||||
expect(container.textContent).toContain("Toggle the app appearance.");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("calls onAfterToggle after toggling (used by SidebarAccountMenu to close the popover)", async () => {
|
||||
const onAfterToggle = vi.fn();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<ThemeToggle variant="menu-action" onAfterToggle={onAfterToggle} />);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const button = container.querySelector("button");
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
|
||||
expect(onAfterToggle).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("flips label and icon when current theme is light", async () => {
|
||||
mockTheme.value = "light";
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<ThemeToggle />);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button?.getAttribute("aria-label")).toBe("Switch to dark mode");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
|
||||
type ThemeToggleVariant = "icon" | "menu-action";
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
/**
|
||||
* `icon` (default): compact icon button — suitable for headers,
|
||||
* floating chrome (e.g. the unauthenticated `/auth` page), and any
|
||||
* other surface that just wants a toggle affordance.
|
||||
*
|
||||
* `menu-action`: full-width row with label + description + icon —
|
||||
* matches the surrounding `MenuAction` rows in `SidebarAccountMenu`.
|
||||
*/
|
||||
variant?: ThemeToggleVariant;
|
||||
/**
|
||||
* Called after `toggleTheme` runs. Surfaces like a popover menu use
|
||||
* this to dismiss the menu once the user has acted.
|
||||
*/
|
||||
onAfterToggle?: () => void;
|
||||
}
|
||||
|
||||
const MENU_ACTION_DESCRIPTION = "Toggle the app appearance.";
|
||||
|
||||
/**
|
||||
* Canonical theme-toggle widget. Both the signed-out `/auth` chrome and
|
||||
* the in-app account menu render through this component so the label,
|
||||
* icon, and toggle behaviour stay in sync as the theme model evolves.
|
||||
*/
|
||||
export function ThemeToggle({ className, variant = "icon", onAfterToggle }: ThemeToggleProps) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const label = isDark ? "Switch to light mode" : "Switch to dark mode";
|
||||
const Icon = isDark ? Sun : Moon;
|
||||
|
||||
function handleClick() {
|
||||
toggleTheme();
|
||||
onAfterToggle?.();
|
||||
}
|
||||
|
||||
if (variant === "menu-action") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
aria-label={label}
|
||||
>
|
||||
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{MENU_ACTION_DESCRIPTION}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn("text-muted-foreground", className)}
|
||||
>
|
||||
<Icon />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,17 @@ vi.mock("@/components/AsciiArtAnimation", () => ({
|
||||
AsciiArtAnimation: () => null,
|
||||
}));
|
||||
|
||||
// The auth page renders a ThemeToggle, which reads ThemeContext. The provider
|
||||
// lives in main.tsx (above the router), so mock the hook here the same way
|
||||
// SidebarAccountMenu.test.tsx does.
|
||||
vi.mock("../context/ThemeContext", () => ({
|
||||
useTheme: () => ({
|
||||
theme: "dark",
|
||||
setTheme: vi.fn(),
|
||||
toggleTheme: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// The router's navigate wrapper reads the active company prefix from context.
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { getRememberedInvitePath } from "../lib/invite-memory";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AsciiArtAnimation } from "@/components/AsciiArtAnimation";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
type AuthMode = "sign_in" | "sign_up";
|
||||
@@ -75,6 +76,9 @@ export function AuthPage() {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex bg-background">
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{/* Left half — form */}
|
||||
<div className="w-full md:w-1/2 flex flex-col overflow-y-auto">
|
||||
<div className="w-full max-w-md mx-auto my-auto px-8 py-12">
|
||||
|
||||
Reference in New Issue
Block a user