From 8ddd735a7ad013bf5296bdfd66505d3d9903487b Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Fri, 12 Jun 2026 21:59:14 +0200 Subject: [PATCH] feat(ui): theme toggle on unauthenticated auth page (supersedes part of #3732) (#5874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ` setOpen(false)} />`. Sun/Moon icon imports and theme state move with it. - **`ui/src/pages/Auth.tsx`** — unchanged from round 0; renders `` 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) Co-authored-by: Devin Foley --- ui/src/components/SidebarAccountMenu.tsx | 15 +--- ui/src/components/ThemeToggle.test.tsx | 107 +++++++++++++++++++++++ ui/src/components/ThemeToggle.tsx | 80 +++++++++++++++++ ui/src/pages/Auth.test.tsx | 11 +++ ui/src/pages/Auth.tsx | 4 + 5 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 ui/src/components/ThemeToggle.test.tsx create mode 100644 ui/src/components/ThemeToggle.tsx diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index 593feecd..f151cb16 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -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)} /> - { - toggleTheme(); - setOpen(false); - }} - /> + setOpen(false)} /> {deploymentMode === "authenticated" ? ( + ); + } + + return ( + + ); +} diff --git a/ui/src/pages/Auth.test.tsx b/ui/src/pages/Auth.test.tsx index 39682c34..47be0676 100644 --- a/ui/src/pages/Auth.test.tsx +++ b/ui/src/pages/Auth.test.tsx @@ -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: () => ({ diff --git a/ui/src/pages/Auth.tsx b/ui/src/pages/Auth.tsx index fc2f7a84..20a8c542 100644 --- a/ui/src/pages/Auth.tsx +++ b/ui/src/pages/Auth.tsx @@ -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 (
+
+ +
{/* Left half — form */}