Skip to content

Commit ee88a8e

Browse files
samejrclaude
andcommitted
feat(webapp): switch themes from the account menu
Adds an "Appearance" submenu to the account popover listing System, Light and Dark, with a check against the current theme, saved through a new preferences endpoint behind the existing theme switcher flag. The account page's theme dropdown becomes the same icon-based segmented control (with Classic included), and its Theme and Contrast rows now match the layout of the Profile rows above them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 90e8bd5 commit ee88a8e

12 files changed

Lines changed: 423 additions & 169 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Switch between the System, Light and Dark themes from the new Appearance menu in your account menu, without having to open your profile settings.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/** Circle with one half filled — the theme/appearance setting. */
2+
export function AppearanceIcon({ className }: { className?: string }) {
3+
return (
4+
<svg
5+
className={className}
6+
width="24"
7+
height="24"
8+
viewBox="0 0 24 24"
9+
fill="none"
10+
xmlns="http://www.w3.org/2000/svg"
11+
>
12+
<path
13+
d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z"
14+
stroke="currentColor"
15+
strokeWidth="2"
16+
/>
17+
<path
18+
d="M12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16V21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3V8ZM12 8C14.2091 8 16 9.79086 16 12C16 14.2091 14.2091 16 12 16V8Z"
19+
fill="currentColor"
20+
/>
21+
</svg>
22+
);
23+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/** Crescent moon — the dark theme. */
2+
export function MoonIcon({ className }: { className?: string }) {
3+
return (
4+
<svg
5+
className={className}
6+
width="24"
7+
height="24"
8+
viewBox="0 0 24 24"
9+
fill="none"
10+
xmlns="http://www.w3.org/2000/svg"
11+
>
12+
<path
13+
d="M20.9638 12.7674C19.8361 13.5447 18.4693 13.9998 16.9961 13.9998C13.1301 13.9998 9.99609 10.8657 9.99609 6.99975C9.99609 5.52667 10.4511 4.15987 11.2283 3.03223C6.61911 3.42277 3 7.28768 3 11.9979C3 16.9674 7.0286 20.996 11.9981 20.996C16.7084 20.996 20.5734 17.3767 20.9638 12.7674Z"
14+
stroke="currentColor"
15+
strokeWidth="2"
16+
strokeLinecap="round"
17+
strokeLinejoin="round"
18+
/>
19+
</svg>
20+
);
21+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Sun with rays — the light theme. The source artwork wrapped this in a mask and
3+
* a clip path; both were no-ops at this viewBox, and dropping them keeps the
4+
* markup free of ids that would collide when the icon renders more than once.
5+
*/
6+
export function SunIcon({ className }: { className?: string }) {
7+
return (
8+
<svg
9+
className={className}
10+
width="24"
11+
height="24"
12+
viewBox="0 0 24 24"
13+
fill="none"
14+
xmlns="http://www.w3.org/2000/svg"
15+
>
16+
<path
17+
fillRule="evenodd"
18+
clipRule="evenodd"
19+
d="M15.5355 8.46447C17.4882 10.4171 17.4882 13.5829 15.5355 15.5355C13.5829 17.4882 10.4171 17.4882 8.46447 15.5355C6.51184 13.5829 6.51184 10.4171 8.46447 8.46447C10.4171 6.51184 13.5829 6.51184 15.5355 8.46447Z"
20+
stroke="currentColor"
21+
strokeWidth="2"
22+
strokeLinecap="round"
23+
strokeLinejoin="round"
24+
/>
25+
<path
26+
d="M12 3V1M12 23V21M21 12H23M1 12H3M5.63603 5.63604L4.22182 4.22183M19.7782 19.7782L18.364 18.364M18.364 5.63606L19.7782 4.22184M4.22183 19.7782L5.63605 18.364"
27+
stroke="currentColor"
28+
strokeWidth="2"
29+
strokeLinecap="round"
30+
strokeLinejoin="round"
31+
/>
32+
</svg>
33+
);
34+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/** Toggle switch, knob to the left. */
2+
export function ToggleSwitchIcon({ className }: { className?: string }) {
3+
return (
4+
<svg
5+
className={className}
6+
width="24"
7+
height="24"
8+
viewBox="0 0 24 24"
9+
fill="none"
10+
xmlns="http://www.w3.org/2000/svg"
11+
>
12+
<path
13+
d="M15.5 5H8.5C4.63401 5 1.5 8.13401 1.5 12C1.5 15.866 4.63401 19 8.5 19H15.5C19.366 19 22.5 15.866 22.5 12C22.5 8.13401 19.366 5 15.5 5Z"
14+
stroke="currentColor"
15+
strokeWidth="2"
16+
/>
17+
<path
18+
d="M8.5 15C10.1569 15 11.5 13.6569 11.5 12C11.5 10.3431 10.1569 9 8.5 9C6.84315 9 5.5 10.3431 5.5 12C5.5 13.6569 6.84315 15 8.5 15Z"
19+
fill="currentColor"
20+
/>
21+
</svg>
22+
);
23+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import SegmentedControl from "~/components/primitives/SegmentedControl";
2+
import { SimpleTooltip } from "~/components/primitives/Tooltip";
3+
import { CLASSIC_OPTION, THEME_OPTIONS, type ThemeOption } from "~/components/themeOptions";
4+
import { type ThemePreference } from "~/utils/themePreference";
5+
6+
/**
7+
* Icon-only segmented control for picking a theme. Every segment is labelled by
8+
* a tooltip and screen-reader text.
9+
*
10+
* `name` must be unique per mounted instance: the underlying control animates
11+
* its selection with a shared `layoutId` derived from it, so two instances with
12+
* the same name would fight over one indicator.
13+
*/
14+
export function ThemeSegmentedControl({
15+
name,
16+
value,
17+
onChange,
18+
includeClassic = false,
19+
}: {
20+
name: string;
21+
/** A value outside the offered segments (e.g. `classic` when it isn't
22+
* included) simply leaves the control with nothing selected. */
23+
value: ThemePreference;
24+
onChange: (theme: ThemePreference) => void;
25+
includeClassic?: boolean;
26+
}) {
27+
const segments = includeClassic ? [...THEME_OPTIONS, CLASSIC_OPTION] : THEME_OPTIONS;
28+
29+
return (
30+
<SegmentedControl
31+
name={name}
32+
value={value}
33+
variant="secondary/small"
34+
onChange={(theme) => onChange(theme as ThemePreference)}
35+
options={segments.map((segment) => ({
36+
value: segment.value,
37+
label: <ThemeSegmentLabel segment={segment} />,
38+
}))}
39+
/>
40+
);
41+
}
42+
43+
function ThemeSegmentLabel({ segment }: { segment: ThemeOption }) {
44+
return (
45+
<SimpleTooltip
46+
asChild
47+
button={
48+
// -mx-0.5 tightens the icon segment toward a square button.
49+
<span className="-mx-0.5 flex items-center justify-center">
50+
<segment.icon className="size-4" />
51+
<span className="sr-only">{segment.label}</span>
52+
</span>
53+
}
54+
content={segment.label}
55+
className="px-2 py-1.5 text-xs"
56+
sideOffset={6}
57+
disableHoverableContent
58+
/>
59+
);
60+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useFetcher } from "@remix-run/react";
2+
import { useTypedRouteLoaderData } from "remix-typedjson";
3+
import { ToggleSwitchIcon } from "~/assets/icons/ToggleSwitchIcon";
4+
import { PopoverMenuItem } from "~/components/primitives/Popover";
5+
import { THEME_OPTIONS } from "~/components/themeOptions";
6+
import { type loader as rootLoader } from "~/root";
7+
import { normalizeThemePreference } from "~/utils/themePreference";
8+
import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu";
9+
import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes";
10+
11+
const THEME_ACTION_PATH = "/resources/preferences/theme";
12+
13+
/**
14+
* Theme switcher for the account popover: an "Appearance" submenu listing each theme, with a check
15+
* against the current one. Picking a theme doesn't navigate, so the menu stays open and the new
16+
* theme applies underneath it. Hidden entirely while the theme switcher feature flag is off,
17+
* matching the account page.
18+
*/
19+
export function AppearanceMenuItem() {
20+
const rootData = useTypedRouteLoaderData<typeof rootLoader>("root");
21+
const fetcher = useFetcher();
22+
23+
if (!rootData?.showThemeSwitcher) {
24+
return null;
25+
}
26+
27+
// Move the check as soon as a theme is clicked; the theme itself follows once
28+
// the write lands and the root loader revalidates.
29+
const pendingTheme = fetcher.formData?.get("theme");
30+
const theme =
31+
typeof pendingTheme === "string"
32+
? normalizeThemePreference(pendingTheme)
33+
: rootData.themePreference;
34+
35+
return (
36+
// Half the standard submenu width: three short labels don't need the room.
37+
<SideMenuPopoverSubMenu title="Appearance" icon={ToggleSwitchIcon} contentClassName="min-w-32">
38+
<div className="flex flex-col gap-1 p-1">
39+
{THEME_OPTIONS.map((option) => (
40+
<PopoverMenuItem
41+
key={option.value}
42+
title={option.label}
43+
icon={option.icon}
44+
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
45+
className={SIDE_MENU_POPOVER_ITEM_LABEL}
46+
isSelected={theme === option.value}
47+
onClick={() =>
48+
fetcher.submit({ theme: option.value }, { method: "post", action: THEME_ACTION_PATH })
49+
}
50+
/>
51+
))}
52+
</div>
53+
</SideMenuPopoverSubMenu>
54+
);
55+
}

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 5 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import {
2-
ArrowTopRightOnSquareIcon,
3-
ChevronRightIcon,
4-
ExclamationTriangleIcon,
5-
} from "@heroicons/react/24/outline";
1+
import { ArrowTopRightOnSquareIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
62
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
73
import {
84
Form,
@@ -135,7 +131,7 @@ import {
135131
import { FreePlanUsage } from "../billing/FreePlanUsage";
136132
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
137133
import { AlphaBadge, NewBadge } from "../FeatureBadges";
138-
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
134+
import { Button, LinkButton } from "../primitives/Buttons";
139135
import { Dialog, DialogTrigger } from "../primitives/Dialog";
140136
import { type RenderIcon } from "../primitives/Icon";
141137
import { Paragraph } from "../primitives/Paragraph";
@@ -158,6 +154,7 @@ import {
158154
} from "../primitives/Tooltip";
159155
import { ShortcutsAutoOpen } from "../Shortcuts";
160156
import { type FavoritePage } from "~/services/dashboardPreferences.server";
157+
import { AppearanceMenuItem } from "./AppearanceMenuItem";
161158
import {
162159
CustomizeSidebarDialog,
163160
type CustomizeSidebarSection,
@@ -178,6 +175,7 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
178175
import { NotificationPanel } from "./NotificationPanel";
179176
import { SideMenuHeader } from "./SideMenuHeader";
180177
import { SideMenuItem, SideMenuLabel } from "./SideMenuItem";
178+
import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu";
181179
import { SideMenuSection } from "./SideMenuSection";
182180
import {
183181
isItemHidden,
@@ -1898,6 +1896,7 @@ function AccountMenuItems({
18981896
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
18991897
className={SIDE_MENU_POPOVER_ITEM_LABEL}
19001898
/>
1899+
<AppearanceMenuItem />
19011900
<PopoverMenuItem
19021901
to={personalAccessTokensPath()}
19031902
title="Personal Access Tokens"
@@ -2102,80 +2101,6 @@ function ProjectSelector({
21022101
);
21032102
}
21042103

2105-
/**
2106-
* Hover-expandable submenu row for side-menu popovers (Account, Switch organization, Integrations):
2107-
* a menu item with a trailing chevron that reveals `children` in a popover to the right, with a
2108-
* short close delay so the pointer can cross the gap.
2109-
*/
2110-
function SideMenuPopoverSubMenu({
2111-
title,
2112-
icon,
2113-
leadingIconClassName,
2114-
children,
2115-
}: {
2116-
title: string;
2117-
icon: RenderIcon;
2118-
leadingIconClassName?: string;
2119-
children: ReactNode;
2120-
}) {
2121-
const navigation = useNavigation();
2122-
const [isOpen, setIsOpen] = useState(false);
2123-
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
2124-
2125-
useEffect(() => {
2126-
return () => {
2127-
if (timeoutRef.current) clearTimeout(timeoutRef.current);
2128-
};
2129-
}, []);
2130-
2131-
// Close the submenu on navigation (the parent popover closes too).
2132-
useEffect(() => {
2133-
setIsOpen(false);
2134-
}, [navigation.location?.pathname]);
2135-
2136-
const openNow = () => {
2137-
if (timeoutRef.current) clearTimeout(timeoutRef.current);
2138-
setIsOpen(true);
2139-
};
2140-
const closeSoon = () => {
2141-
// Small delay before closing so the pointer can move onto the content.
2142-
timeoutRef.current = setTimeout(() => setIsOpen(false), 150);
2143-
};
2144-
2145-
return (
2146-
<Popover onOpenChange={(open) => setIsOpen(open)} open={isOpen}>
2147-
<div onMouseEnter={openNow} onMouseLeave={closeSoon} className="flex">
2148-
<PopoverTrigger className="w-full justify-between overflow-hidden focus-custom">
2149-
<ButtonContent
2150-
variant="small-menu-item"
2151-
className={cn("hover:bg-background-hover", SIDE_MENU_POPOVER_ITEM_LABEL)}
2152-
LeadingIcon={icon}
2153-
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, leadingIconClassName)}
2154-
TrailingIcon={ChevronRightIcon}
2155-
trailingIconClassName="text-text-dimmed"
2156-
textAlignLeft
2157-
fullWidth
2158-
>
2159-
{title}
2160-
</ButtonContent>
2161-
</PopoverTrigger>
2162-
<PopoverContent
2163-
className="min-w-64 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
2164-
align="start"
2165-
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
2166-
side="right"
2167-
alignOffset={0}
2168-
sideOffset={-4}
2169-
onMouseEnter={openNow}
2170-
onMouseLeave={closeSoon}
2171-
>
2172-
{children}
2173-
</PopoverContent>
2174-
</div>
2175-
</Popover>
2176-
);
2177-
}
2178-
21792104
function SwitchOrganizations({
21802105
organizations,
21812106
organization,

0 commit comments

Comments
 (0)