Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ee88a8e
feat(webapp): switch themes from the account menu
samejr Aug 10, 2026
54939fe
feat(webapp): more options entry, theme select and a finer contrast s…
samejr Aug 10, 2026
41a9789
fix(webapp): stop the contrast label sticking, tighten the theme picker
samejr Aug 10, 2026
47d3246
feat(webapp): mark the default contrast on the slider
samejr Aug 10, 2026
4d5645d
fix(webapp): widen the theme select and correct the tooltip arrow
samejr Aug 10, 2026
4c6ddfb
fix(webapp): notch the contrast track at the default mark
samejr Aug 10, 2026
5c9de3d
fix(webapp): notch the contrast mark against the real page colour
samejr Aug 10, 2026
2a1e291
fix(webapp): widen the gap either side of the contrast mark
samejr Aug 10, 2026
74802cf
fix(webapp): thicken the contrast mark line to 2px
samejr Aug 10, 2026
e0a58ca
feat(webapp): click the contrast mark to reset to the default
samejr Aug 10, 2026
0aeee45
feat(webapp): split icon and badge accents out into an Icon contrast …
samejr Aug 10, 2026
34ad34f
feat(webapp): add an App sidebar row and descriptions to the profile …
samejr Aug 10, 2026
ade0691
fix(webapp): tighten settings row descriptions
samejr Aug 10, 2026
07d62a1
feat(webapp): open the customize sidebar modal from the profile page
samejr Aug 10, 2026
f1b7413
fix(webapp): apply the theme on click, not on the loader's next pass
samejr Aug 11, 2026
23533dd
fix(webapp): brighten the secondary button on hover in the dark themes
samejr Aug 11, 2026
77ed110
fix(webapp): brighten the secondary select on hover in the dark themes
samejr Aug 11, 2026
b80cc8c
feat(webapp): add a surround-free switch variant and move it to indigo
samejr Aug 11, 2026
e0b7a8a
feat(webapp): drop the switch surround on the onboarding emails row
samejr Aug 11, 2026
3037a21
fix(webapp): dim the contrast handle and brighten it on hover
samejr Aug 11, 2026
d9866d8
fix(webapp): step the Customize button and theme select down a size
samejr Aug 11, 2026
641dc25
feat(webapp): floor the contrast slider at 15% and mark the default a…
samejr Aug 11, 2026
bb48233
feat(webapp): add an Underline links preference
samejr Aug 11, 2026
f2adfeb
chore(webapp): temporary underline audit story
samejr Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/webapp/app/assets/icons/AppearanceIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** Circle with one half filled — the theme/appearance setting. */
export function AppearanceIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
stroke="currentColor"
strokeWidth="2"
/>
<path
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"
fill="currentColor"
/>
</svg>
);
}
21 changes: 21 additions & 0 deletions apps/webapp/app/assets/icons/MoonIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** Crescent moon — the dark theme. */
export function MoonIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
34 changes: 34 additions & 0 deletions apps/webapp/app/assets/icons/SunIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Sun with rays — the light theme. The source artwork wrapped this in a mask and
* a clip path; both were no-ops at this viewBox, and dropping them keeps the
* markup free of ids that would collide when the icon renders more than once.
*/
export function SunIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
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"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
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"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
23 changes: 23 additions & 0 deletions apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** Toggle switch, knob to the left. */
export function ToggleSwitchIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
stroke="currentColor"
strokeWidth="2"
/>
<path
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"
fill="currentColor"
/>
</svg>
);
}
82 changes: 82 additions & 0 deletions apps/webapp/app/components/navigation/AppearanceMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
import { useFetcher } from "@remix-run/react";
import { useEffect } from "react";
import { useTypedRouteLoaderData } from "remix-typedjson";
import { ToggleSwitchIcon } from "~/assets/icons/ToggleSwitchIcon";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { THEME_OPTIONS } from "~/components/themeOptions";
import { applyThemePreference } from "~/hooks/useSystemThemeSync";
import { type loader as rootLoader } from "~/root";
import { accountPath } from "~/utils/pathBuilder";
import { normalizeThemePreference, type ThemePreference } from "~/utils/themePreference";
import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu";
import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes";

const THEME_ACTION_PATH = "/resources/preferences/theme";

/**
* Theme switcher for the account popover: an "Appearance" submenu listing each theme, with a check
* against the current one. Picking a theme doesn't navigate, so the menu stays open and the new
* theme applies underneath it. Hidden entirely while the theme switcher feature flag is off,
* matching the account page.
*/
export function AppearanceMenuItem() {
const rootData = useTypedRouteLoaderData<typeof rootLoader>("root");
const fetcher = useFetcher<{ success?: boolean }>();
const savedTheme = rootData?.themePreference;

// A failed write would otherwise leave the optimistic theme on screen, since
// the loader data never changes and so `useSystemThemeSync` never re-runs.
useEffect(() => {
if (fetcher.state !== "idle" || !fetcher.data || fetcher.data.success || !savedTheme) return;
applyThemePreference(savedTheme);
}, [fetcher.state, fetcher.data, savedTheme]);

if (!rootData?.showThemeSwitcher) {
return null;
}

// Move the check as soon as a theme is clicked; the write follows.
const pendingTheme = fetcher.formData?.get("theme");
const theme =
typeof pendingTheme === "string"
? normalizeThemePreference(pendingTheme)
: rootData.themePreference;

const pickTheme = (value: ThemePreference) => {
// Applied here rather than waiting for the write to come back through the
// root loader: dismissing the popover unmounts this row, and an unmounted
// fetcher's revalidation is dropped, which left the theme untouched even
// though the preference had saved.
applyThemePreference(value);
fetcher.submit({ theme: value }, { method: "post", action: THEME_ACTION_PATH });
};

return (
// Much narrower than the standard submenu: these labels don't need the room.
<SideMenuPopoverSubMenu title="Appearance" icon={ToggleSwitchIcon} contentClassName="min-w-36">
<div className="flex flex-col gap-1 p-1">
{THEME_OPTIONS.map((option) => (
<PopoverMenuItem
key={option.value}
title={option.label}
icon={option.icon}
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
className={SIDE_MENU_POPOVER_ITEM_LABEL}
isSelected={theme === option.value}
onClick={() => pickTheme(option.value)}
/>
))}
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<PopoverMenuItem
to={accountPath()}
title="More options"
icon={EllipsisHorizontalIcon}
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
className={SIDE_MENU_POPOVER_ITEM_LABEL}
/>
</div>
</SideMenuPopoverSubMenu>
);
}
Loading
Loading