Skip to content

Commit 948db74

Browse files
samejrclaude
andcommitted
feat(webapp): swap the Black and White icons with the active theme
Replaces the placeholder circles with the supplied artwork, and picks between them per active theme: the option matching the current end of the scale draws as a ring so the background reads through it, the opposing one as a solid disc in the foreground colour. Dark and Black show Black as a ring, Light and White show White as a ring. The appearance resolution joins the other theme resolution in useSystemThemeSync, and defaults to dark before the effect runs so the first client render agrees with the server's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5175479 commit 948db74

5 files changed

Lines changed: 80 additions & 13 deletions

File tree

apps/webapp/app/assets/icons/CircleFilledIcon.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
/** Solid circle — the Black theme, whose surfaces are filled flat. */
1+
/** Solid circle. Paired with {@link CircleOutlineIcon} by the Black and White
2+
* theme options — the filled disc reads as the opposite of the active theme. */
23
export function CircleFilledIcon({ className }: { className?: string }) {
34
return (
45
<svg

apps/webapp/app/assets/icons/CircleOutlineIcon.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
/** Hollow circle — the White theme, the counterpart to {@link CircleFilledIcon}. */
1+
/** Hollow circle. Paired with {@link CircleFilledIcon} by the Black and White
2+
* theme options, which show the active theme's background through the ring. */
23
export function CircleOutlineIcon({ className }: { className?: string }) {
34
return (
45
<svg
@@ -9,7 +10,7 @@ export function CircleOutlineIcon({ className }: { className?: string }) {
910
fill="none"
1011
xmlns="http://www.w3.org/2000/svg"
1112
>
12-
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
13+
<circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="2" />
1314
</svg>
1415
);
1516
}

apps/webapp/app/components/themeOptions.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { CircleFilledIcon } from "~/assets/icons/CircleFilledIcon";
44
import { CircleOutlineIcon } from "~/assets/icons/CircleOutlineIcon";
55
import { MoonIcon } from "~/assets/icons/MoonIcon";
66
import { SunIcon } from "~/assets/icons/SunIcon";
7+
import { type ThemeAppearance } from "~/hooks/useSystemThemeSync";
78
import { type ThemePreference } from "~/utils/themePreference";
89

910
export type ThemeOption = {
@@ -22,10 +23,11 @@ export const THEME_OPTIONS: ThemeOption[] = [
2223
];
2324

2425
/** Dark and Light with their surfaces pinned flat, so grid lines carry the
25-
* layout. Account page only, alongside Classic. */
26+
* layout. Account page only, alongside Classic. The icons here are the
27+
* dark-theme pair; `themeOptionIcon` swaps them per active theme. */
2628
const FLAT_OPTIONS: ThemeOption[] = [
27-
{ value: "black", label: "Black", icon: CircleFilledIcon },
28-
{ value: "white", label: "White", icon: CircleOutlineIcon },
29+
{ value: "black", label: "Black", icon: CircleOutlineIcon },
30+
{ value: "white", label: "White", icon: CircleFilledIcon },
2931
];
3032

3133
/** Legacy theme, offered on the account page only. */
@@ -41,3 +43,22 @@ export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, ...FLAT_OPTIO
4143
export const THEME_OPTIONS_BY_VALUE = Object.fromEntries(
4244
ALL_THEME_OPTIONS.map((option) => [option.value, option])
4345
) as Record<ThemePreference, ThemeOption>;
46+
47+
/**
48+
* The icon to draw for an option under the active theme.
49+
*
50+
* Black and White show the active theme's background *through* the circle: the
51+
* option matching the current end of the scale is a ring, so the background
52+
* reads through it, and the opposing one is a solid disc in the foreground
53+
* colour. On a dark theme that makes Black a ring and White a filled disc; on a
54+
* light theme it flips. Every other option has one fixed icon.
55+
*/
56+
export function themeOptionIcon(option: ThemeOption, appearance: ThemeAppearance) {
57+
if (option.value === "black") {
58+
return appearance === "dark" ? CircleOutlineIcon : CircleFilledIcon;
59+
}
60+
if (option.value === "white") {
61+
return appearance === "light" ? CircleOutlineIcon : CircleFilledIcon;
62+
}
63+
return option.icon;
64+
}

apps/webapp/app/hooks/useSystemThemeSync.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
1-
import { useEffect } from "react";
1+
import { useEffect, useState } from "react";
22
import { type ThemePreference } from "~/utils/themePreference";
33

4+
/** Which end of the scale a theme sits on. Classic and Black are dark; White is
5+
* light; `system` follows the OS. */
6+
export type ThemeAppearance = "dark" | "light";
7+
8+
export function themeAppearance(
9+
preference: ThemePreference,
10+
prefersDark: boolean
11+
): ThemeAppearance {
12+
if (preference === "system") return prefersDark ? "dark" : "light";
13+
return preference === "light" || preference === "white" ? "light" : "dark";
14+
}
15+
16+
/**
17+
* The resolved appearance, tracking OS changes while the preference is `system`.
18+
*
19+
* Defaults to dark before the effect runs, matching the SSR fallback in root.tsx,
20+
* so the first client render agrees with the server's.
21+
*/
22+
export function useThemeAppearance(preference: ThemePreference): ThemeAppearance {
23+
const [prefersDark, setPrefersDark] = useState(true);
24+
25+
useEffect(() => {
26+
if (preference !== "system") return;
27+
const media = window.matchMedia("(prefers-color-scheme: dark)");
28+
const apply = () => setPrefersDark(media.matches);
29+
apply();
30+
media.addEventListener("change", apply);
31+
return () => media.removeEventListener("change", apply);
32+
}, [preference]);
33+
34+
return themeAppearance(preference, prefersDark);
35+
}
36+
437
/**
538
* Puts a preference on <html> now, resolving `system` against the OS once. Use
639
* this to apply a theme the moment it's picked: the preference round-trips

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,19 @@ import {
3535
useFavorites,
3636
} from "~/components/navigation/favoritePages";
3737
import { buildSideMenuSections } from "~/components/navigation/sideMenuSections";
38-
import { ALL_THEME_OPTIONS, THEME_OPTIONS_BY_VALUE } from "~/components/themeOptions";
38+
import {
39+
ALL_THEME_OPTIONS,
40+
THEME_OPTIONS_BY_VALUE,
41+
themeOptionIcon,
42+
} from "~/components/themeOptions";
3943
import { prisma } from "~/db.server";
4044
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
4145
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
42-
import { applyThemePreference } from "~/hooks/useSystemThemeSync";
46+
import {
47+
applyThemePreference,
48+
type ThemeAppearance,
49+
useThemeAppearance,
50+
} from "~/hooks/useSystemThemeSync";
4351
import { useFeatures } from "~/hooks/useFeatures";
4452
import { useHasAdminAccess, useUser } from "~/hooks/useUser";
4553
import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -74,8 +82,8 @@ const MIN_CONTRAST = 15;
7482
* same as `DEFAULT_THEME_CONTRAST`, the value applied when none is saved. */
7583
const DEFAULT_CONTRAST_MARK = 30;
7684

77-
function themeIcon(value: ThemePreference) {
78-
const Icon = THEME_OPTIONS_BY_VALUE[value].icon;
85+
function themeIcon(value: ThemePreference, appearance: ThemeAppearance) {
86+
const Icon = themeOptionIcon(THEME_OPTIONS_BY_VALUE[value], appearance);
7987
// shrink-0: without it the icon is the flex item that gives way to a long
8088
// label, and "System"/"Classic" squash it to a sliver.
8189
return <Icon className="size-4 shrink-0 text-text-bright" />;
@@ -392,6 +400,9 @@ export default function Page() {
392400
typeof pendingTheme === "string"
393401
? normalizeThemePreference(pendingTheme)
394402
: normalizeThemePreference(user.dashboardPreferences.theme);
403+
// Black and White draw themselves against the active theme, so the icons
404+
// follow the optimistic pick rather than waiting for the write to land.
405+
const appearance = useThemeAppearance(theme);
395406

396407
// Dragging previews the contrast via the CSS var before it persists; once the
397408
// save settles, resnap the page and the thumb to the stored value so a failed
@@ -545,7 +556,7 @@ export default function Page() {
545556
items={ALL_THEME_OPTIONS.map((option) => option.value)}
546557
text={(value) => (
547558
<span className="flex items-center gap-1.5">
548-
{themeIcon(value)}
559+
{themeIcon(value, appearance)}
549560
{THEME_OPTIONS_BY_VALUE[value].label}
550561
</span>
551562
)}
@@ -561,7 +572,7 @@ export default function Page() {
561572
<SelectItem
562573
key={item}
563574
value={item}
564-
icon={themeIcon(item)}
575+
icon={themeIcon(item, appearance)}
565576
className="text-text-bright"
566577
>
567578
{THEME_OPTIONS_BY_VALUE[item].label}

0 commit comments

Comments
 (0)