Skip to content

Commit 54939fe

Browse files
samejrclaude
andcommitted
feat(webapp): more options entry, theme select and a finer contrast slider
Adds a "More options" link to the Appearance submenu pointing at the profile page, where the theme picker goes back to the standard select popover and now covers Classic too. The contrast slider moves from steps of 5 to 1 and gains a label above the handle showing the percentage while hovering, dragging or focused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ee88a8e commit 54939fe

6 files changed

Lines changed: 89 additions & 76 deletions

File tree

.server-changes/appearance-toggle-in-account-menu.md

Lines changed: 0 additions & 6 deletions
This file was deleted.

apps/webapp/app/components/ThemeSegmentedControl.tsx

Lines changed: 0 additions & 60 deletions
This file was deleted.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
12
import { useFetcher } from "@remix-run/react";
23
import { useTypedRouteLoaderData } from "remix-typedjson";
34
import { ToggleSwitchIcon } from "~/assets/icons/ToggleSwitchIcon";
45
import { PopoverMenuItem } from "~/components/primitives/Popover";
56
import { THEME_OPTIONS } from "~/components/themeOptions";
67
import { type loader as rootLoader } from "~/root";
8+
import { accountPath } from "~/utils/pathBuilder";
79
import { normalizeThemePreference } from "~/utils/themePreference";
810
import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu";
911
import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes";
@@ -50,6 +52,15 @@ export function AppearanceMenuItem() {
5052
/>
5153
))}
5254
</div>
55+
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
56+
<PopoverMenuItem
57+
to={accountPath()}
58+
title="More options"
59+
icon={EllipsisHorizontalIcon}
60+
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
61+
className={SIDE_MENU_POPOVER_ITEM_LABEL}
62+
/>
63+
</div>
5364
</SideMenuPopoverSubMenu>
5465
);
5566
}

apps/webapp/app/components/primitives/Slider.tsx

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as RadixSlider from "@radix-ui/react-slider";
2-
import type { ComponentProps } from "react";
2+
import { type ComponentProps, useState } from "react";
33
import { cn } from "~/utils/cn";
44
import type { RenderIcon } from "./Icon";
55
import { Icon } from "./Icon";
@@ -34,6 +34,12 @@ export type SliderProps = ComponentProps<typeof RadixSlider.Root> & {
3434
LeadingIcon?: RenderIcon;
3535
TrailingIcon?: RenderIcon;
3636
variant: VariantName;
37+
/**
38+
* Opts into a small label above the thumb showing the formatted value, while
39+
* hovering, dragging, or focused via the keyboard. It sits inside the thumb,
40+
* so it tracks the handle exactly. Reads the controlled `value`, so pass one.
41+
*/
42+
valueTooltip?: (value: number) => string;
3743
};
3844

3945
export function Slider({
@@ -42,9 +48,15 @@ export function Slider({
4248
LeadingIcon,
4349
TrailingIcon,
4450
"aria-label": ariaLabel,
51+
valueTooltip,
4552
...props
4653
}: SliderProps) {
4754
const variation = variants[variant];
55+
// The pointer leaves the thumb while dragging, so hover alone can't keep the
56+
// label up.
57+
const [isDragging, setIsDragging] = useState(false);
58+
const currentValue = props.value?.[0] ?? props.defaultValue?.[0] ?? 0;
59+
4860
return (
4961
<div className={cn("group flex items-center", variation.container)}>
5062
{LeadingIcon && <Icon icon={LeadingIcon} className={variation.icons} />}
@@ -55,6 +67,18 @@ export function Slider({
5567
className
5668
)}
5769
{...props}
70+
onPointerDown={(event) => {
71+
props.onPointerDown?.(event);
72+
setIsDragging(true);
73+
}}
74+
onPointerUp={(event) => {
75+
props.onPointerUp?.(event);
76+
setIsDragging(false);
77+
}}
78+
onPointerCancel={(event) => {
79+
props.onPointerCancel?.(event);
80+
setIsDragging(false);
81+
}}
5882
>
5983
<RadixSlider.Track className={cn("relative grow rounded-full", variation.track)}>
6084
<RadixSlider.Range className={cn("absolute h-full rounded-full", variation.range)} />
@@ -63,10 +87,23 @@ export function Slider({
6387
<RadixSlider.Thumb
6488
aria-label={ariaLabel}
6589
className={cn(
66-
"block cursor-pointer rounded-full transition focus:outline-hidden",
90+
"group/thumb relative block cursor-pointer rounded-full transition focus:outline-hidden",
6791
variation.thumb
6892
)}
69-
/>
93+
>
94+
{valueTooltip && (
95+
<span
96+
className={cn(
97+
"pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 rounded border border-grid-bright bg-background-bright px-1.5 py-0.5 text-xs tabular-nums text-text-bright shadow-md transition-opacity",
98+
isDragging
99+
? "opacity-100"
100+
: "opacity-0 group-hover/thumb:opacity-100 group-focus-visible/thumb:opacity-100"
101+
)}
102+
>
103+
{valueTooltip(currentValue)}
104+
</span>
105+
)}
106+
</RadixSlider.Thumb>
70107
</RadixSlider.Root>
71108
{TrailingIcon && <Icon icon={TrailingIcon} className={variation.icons} />}
72109
</div>

apps/webapp/app/components/themeOptions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,10 @@ export const CLASSIC_OPTION: ThemeOption = {
2424
label: "Classic",
2525
icon: SwatchIcon,
2626
};
27+
28+
/** Every theme, for the account page's full picker. */
29+
export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, CLASSIC_OPTION];
30+
31+
export const THEME_OPTIONS_BY_VALUE = Object.fromEntries(
32+
ALL_THEME_OPTIONS.map((option) => [option.value, option])
33+
) as Record<ThemePreference, ThemeOption>;

apps/webapp/app/routes/account._index/route.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import { conformZodMessage, parseWithZod } from "@conform-to/zod";
44
import { Form, useActionData, useFetcher, useLoaderData } from "@remix-run/react";
55
import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
66
import { z } from "zod";
7-
import { ThemeSegmentedControl } from "~/components/ThemeSegmentedControl";
87
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
98
import {
109
MainHorizontallyCenteredContainer,
1110
PageBody,
1211
PageContainer,
1312
} from "~/components/layout/AppLayout";
1413
import { Button } from "~/components/primitives/Buttons";
14+
import { Select, SelectItem } from "~/components/primitives/Select";
1515
import { Slider } from "~/components/primitives/Slider";
1616
import { FormError } from "~/components/primitives/FormError";
1717
import { Header2 } from "~/components/primitives/Headers";
@@ -20,6 +20,7 @@ import { InputGroup } from "~/components/primitives/InputGroup";
2020
import { Label } from "~/components/primitives/Label";
2121
import { Switch } from "~/components/primitives/Switch";
2222
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
23+
import { ALL_THEME_OPTIONS, THEME_OPTIONS_BY_VALUE } from "~/components/themeOptions";
2324
import { prisma } from "~/db.server";
2425
import { useUser } from "~/hooks/useUser";
2526
import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -41,6 +42,11 @@ import { pageMeta } from "~/utils/pageTitle";
4142

4243
export const meta = pageMeta("Your profile");
4344

45+
function themeIcon(value: ThemePreference) {
46+
const Icon = THEME_OPTIONS_BY_VALUE[value].icon;
47+
return <Icon className="size-4 text-text-dimmed" />;
48+
}
49+
4450
function createSchema(
4551
constraints: {
4652
isEmailUnique?: (email: string) => Promise<boolean>;
@@ -281,17 +287,34 @@ export default function Page() {
281287
<Label>Theme</Label>
282288
</InputGroup>
283289
<div className="flex flex-none items-center">
284-
<ThemeSegmentedControl
285-
name="appearance-account"
290+
<Select<ThemePreference, ThemePreference>
291+
aria-label="Theme"
286292
value={theme}
287-
includeClassic
288-
onChange={(value) =>
293+
setValue={(value) =>
289294
themeFetcher.submit(
290295
{ action: "update-theme", theme: value },
291296
{ method: "post" }
292297
)
293298
}
294-
/>
299+
variant="secondary/small"
300+
dropdownIcon
301+
items={ALL_THEME_OPTIONS.map((option) => option.value)}
302+
text={(value) => (
303+
<span className="flex items-center gap-1.5">
304+
{themeIcon(value)}
305+
{THEME_OPTIONS_BY_VALUE[value].label}
306+
</span>
307+
)}
308+
className="w-44"
309+
>
310+
{(items) =>
311+
items.map((item) => (
312+
<SelectItem key={item} value={item} icon={themeIcon(item)}>
313+
{THEME_OPTIONS_BY_VALUE[item].label}
314+
</SelectItem>
315+
))
316+
}
317+
</Select>
295318
</div>
296319
</div>
297320
</div>
@@ -308,7 +331,8 @@ export default function Page() {
308331
aria-label="Contrast"
309332
min={0}
310333
max={100}
311-
step={5}
334+
step={1}
335+
valueTooltip={(value) => `${value}%`}
312336
value={[contrastPreview]}
313337
onValueChange={(values) => {
314338
// Live preview before the preference persists

0 commit comments

Comments
 (0)