feat(ui): theme toggle on unauthenticated auth page (supersedes part of #3732) (#5874)

## 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:
Jannes Stubbemann
2026-06-12 21:59:14 +02:00
committed by GitHub
parent 362c30ccdc
commit 8ddd735a7a
5 changed files with 204 additions and 13 deletions
+2 -13
View File
@@ -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"
+107
View File
@@ -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());
});
});
+80
View File
@@ -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>
);
}
+11
View File
@@ -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: () => ({
+4
View File
@@ -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">