From ee88a8e04496fcea0ac22485098263149716570b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 08:19:26 +0000 Subject: [PATCH 01/25] 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) --- .../appearance-toggle-in-account-menu.md | 6 + .../app/assets/icons/AppearanceIcon.tsx | 23 +++ apps/webapp/app/assets/icons/MoonIcon.tsx | 21 +++ apps/webapp/app/assets/icons/SunIcon.tsx | 34 +++++ .../app/assets/icons/ToggleSwitchIcon.tsx | 23 +++ .../app/components/ThemeSegmentedControl.tsx | 60 ++++++++ .../navigation/AppearanceMenuItem.tsx | 55 +++++++ .../app/components/navigation/SideMenu.tsx | 85 +---------- .../navigation/SideMenuPopoverSubMenu.tsx | 88 +++++++++++ apps/webapp/app/components/themeOptions.ts | 26 ++++ .../app/routes/account._index/route.tsx | 142 +++++++----------- .../app/routes/resources.preferences.theme.ts | 29 ++++ 12 files changed, 423 insertions(+), 169 deletions(-) create mode 100644 .server-changes/appearance-toggle-in-account-menu.md create mode 100644 apps/webapp/app/assets/icons/AppearanceIcon.tsx create mode 100644 apps/webapp/app/assets/icons/MoonIcon.tsx create mode 100644 apps/webapp/app/assets/icons/SunIcon.tsx create mode 100644 apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx create mode 100644 apps/webapp/app/components/ThemeSegmentedControl.tsx create mode 100644 apps/webapp/app/components/navigation/AppearanceMenuItem.tsx create mode 100644 apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx create mode 100644 apps/webapp/app/components/themeOptions.ts create mode 100644 apps/webapp/app/routes/resources.preferences.theme.ts diff --git a/.server-changes/appearance-toggle-in-account-menu.md b/.server-changes/appearance-toggle-in-account-menu.md new file mode 100644 index 00000000000..59e268103a3 --- /dev/null +++ b/.server-changes/appearance-toggle-in-account-menu.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Switch between the System, Light and Dark themes from the new Appearance menu in your account menu, without having to open your profile settings. diff --git a/apps/webapp/app/assets/icons/AppearanceIcon.tsx b/apps/webapp/app/assets/icons/AppearanceIcon.tsx new file mode 100644 index 00000000000..f1fd1451fe7 --- /dev/null +++ b/apps/webapp/app/assets/icons/AppearanceIcon.tsx @@ -0,0 +1,23 @@ +/** Circle with one half filled — the theme/appearance setting. */ +export function AppearanceIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoonIcon.tsx b/apps/webapp/app/assets/icons/MoonIcon.tsx new file mode 100644 index 00000000000..f3e20e27f2c --- /dev/null +++ b/apps/webapp/app/assets/icons/MoonIcon.tsx @@ -0,0 +1,21 @@ +/** Crescent moon — the dark theme. */ +export function MoonIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SunIcon.tsx b/apps/webapp/app/assets/icons/SunIcon.tsx new file mode 100644 index 00000000000..b2ac93fd02a --- /dev/null +++ b/apps/webapp/app/assets/icons/SunIcon.tsx @@ -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 ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx b/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx new file mode 100644 index 00000000000..51b8136e1dc --- /dev/null +++ b/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx @@ -0,0 +1,23 @@ +/** Toggle switch, knob to the left. */ +export function ToggleSwitchIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/ThemeSegmentedControl.tsx b/apps/webapp/app/components/ThemeSegmentedControl.tsx new file mode 100644 index 00000000000..7e83362f65d --- /dev/null +++ b/apps/webapp/app/components/ThemeSegmentedControl.tsx @@ -0,0 +1,60 @@ +import SegmentedControl from "~/components/primitives/SegmentedControl"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { CLASSIC_OPTION, THEME_OPTIONS, type ThemeOption } from "~/components/themeOptions"; +import { type ThemePreference } from "~/utils/themePreference"; + +/** + * Icon-only segmented control for picking a theme. Every segment is labelled by + * a tooltip and screen-reader text. + * + * `name` must be unique per mounted instance: the underlying control animates + * its selection with a shared `layoutId` derived from it, so two instances with + * the same name would fight over one indicator. + */ +export function ThemeSegmentedControl({ + name, + value, + onChange, + includeClassic = false, +}: { + name: string; + /** A value outside the offered segments (e.g. `classic` when it isn't + * included) simply leaves the control with nothing selected. */ + value: ThemePreference; + onChange: (theme: ThemePreference) => void; + includeClassic?: boolean; +}) { + const segments = includeClassic ? [...THEME_OPTIONS, CLASSIC_OPTION] : THEME_OPTIONS; + + return ( + onChange(theme as ThemePreference)} + options={segments.map((segment) => ({ + value: segment.value, + label: , + }))} + /> + ); +} + +function ThemeSegmentLabel({ segment }: { segment: ThemeOption }) { + return ( + + + {segment.label} + + } + content={segment.label} + className="px-2 py-1.5 text-xs" + sideOffset={6} + disableHoverableContent + /> + ); +} diff --git a/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx new file mode 100644 index 00000000000..97f993defee --- /dev/null +++ b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx @@ -0,0 +1,55 @@ +import { useFetcher } from "@remix-run/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 { type loader as rootLoader } from "~/root"; +import { normalizeThemePreference } 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("root"); + const fetcher = useFetcher(); + + if (!rootData?.showThemeSwitcher) { + return null; + } + + // Move the check as soon as a theme is clicked; the theme itself follows once + // the write lands and the root loader revalidates. + const pendingTheme = fetcher.formData?.get("theme"); + const theme = + typeof pendingTheme === "string" + ? normalizeThemePreference(pendingTheme) + : rootData.themePreference; + + return ( + // Half the standard submenu width: three short labels don't need the room. + +
+ {THEME_OPTIONS.map((option) => ( + + fetcher.submit({ theme: option.value }, { method: "post", action: THEME_ACTION_PATH }) + } + /> + ))} +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index ebedc77fc6e..655572121ab 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1,8 +1,4 @@ -import { - ArrowTopRightOnSquareIcon, - ChevronRightIcon, - ExclamationTriangleIcon, -} from "@heroicons/react/24/outline"; +import { ArrowTopRightOnSquareIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; import { Form, @@ -135,7 +131,7 @@ import { import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { AlphaBadge, NewBadge } from "../FeatureBadges"; -import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; +import { Button, LinkButton } from "../primitives/Buttons"; import { Dialog, DialogTrigger } from "../primitives/Dialog"; import { type RenderIcon } from "../primitives/Icon"; import { Paragraph } from "../primitives/Paragraph"; @@ -158,6 +154,7 @@ import { } from "../primitives/Tooltip"; import { ShortcutsAutoOpen } from "../Shortcuts"; import { type FavoritePage } from "~/services/dashboardPreferences.server"; +import { AppearanceMenuItem } from "./AppearanceMenuItem"; import { CustomizeSidebarDialog, type CustomizeSidebarSection, @@ -178,6 +175,7 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { NotificationPanel } from "./NotificationPanel"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem, SideMenuLabel } from "./SideMenuItem"; +import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu"; import { SideMenuSection } from "./SideMenuSection"; import { isItemHidden, @@ -1898,6 +1896,7 @@ function AccountMenuItems({ leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} className={SIDE_MENU_POPOVER_ITEM_LABEL} /> + (null); - - useEffect(() => { - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }; - }, []); - - // Close the submenu on navigation (the parent popover closes too). - useEffect(() => { - setIsOpen(false); - }, [navigation.location?.pathname]); - - const openNow = () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - setIsOpen(true); - }; - const closeSoon = () => { - // Small delay before closing so the pointer can move onto the content. - timeoutRef.current = setTimeout(() => setIsOpen(false), 150); - }; - - return ( - setIsOpen(open)} open={isOpen}> -
- - - {title} - - - - {children} - -
-
- ); -} - function SwitchOrganizations({ organizations, organization, diff --git a/apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx b/apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx new file mode 100644 index 00000000000..ba8057613ea --- /dev/null +++ b/apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx @@ -0,0 +1,88 @@ +import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import { useNavigation } from "@remix-run/react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { cn } from "~/utils/cn"; +import { ButtonContent } from "../primitives/Buttons"; +import { type RenderIcon } from "../primitives/Icon"; +import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover"; +import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes"; + +/** + * Hover-expandable submenu row for side-menu popovers (Account, Switch organization, Integrations, + * Appearance): a menu item with a trailing chevron that reveals `children` in a popover to the + * right, with a short close delay so the pointer can cross the gap. + */ +export function SideMenuPopoverSubMenu({ + title, + icon, + leadingIconClassName, + contentClassName, + children, +}: { + title: string; + icon: RenderIcon; + leadingIconClassName?: string; + /** Override the submenu panel's styling, e.g. a narrower width for short entries. */ + contentClassName?: string; + children: ReactNode; +}) { + const navigation = useNavigation(); + const [isOpen, setIsOpen] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + // Close the submenu on navigation (the parent popover closes too). + useEffect(() => { + setIsOpen(false); + }, [navigation.location?.pathname]); + + const openNow = () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setIsOpen(true); + }; + const closeSoon = () => { + // Small delay before closing so the pointer can move onto the content. + timeoutRef.current = setTimeout(() => setIsOpen(false), 150); + }; + + return ( + setIsOpen(open)} open={isOpen}> +
+ + + {title} + + + + {children} + +
+
+ ); +} diff --git a/apps/webapp/app/components/themeOptions.ts b/apps/webapp/app/components/themeOptions.ts new file mode 100644 index 00000000000..ff25dc01852 --- /dev/null +++ b/apps/webapp/app/components/themeOptions.ts @@ -0,0 +1,26 @@ +import { ComputerDesktopIcon, SwatchIcon } from "@heroicons/react/24/outline"; +import { type FunctionComponent } from "react"; +import { MoonIcon } from "~/assets/icons/MoonIcon"; +import { SunIcon } from "~/assets/icons/SunIcon"; +import { type ThemePreference } from "~/utils/themePreference"; + +export type ThemeOption = { + value: ThemePreference; + label: string; + icon: FunctionComponent<{ className?: string }>; +}; + +/** The themes on offer, in display order. Shared by every theme picker so the + * labels and icons can't drift apart. */ +export const THEME_OPTIONS: ThemeOption[] = [ + { value: "system", label: "System", icon: ComputerDesktopIcon }, + { value: "light", label: "Light", icon: SunIcon }, + { value: "dark", label: "Dark", icon: MoonIcon }, +]; + +/** Legacy theme, offered on the account page only. */ +export const CLASSIC_OPTION: ThemeOption = { + value: "classic", + label: "Classic", + icon: SwatchIcon, +}; diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 39dbf4b4e6e..e31ea10ad0c 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -1,10 +1,10 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { useEffect, useState } from "react"; import { conformZodMessage, parseWithZod } from "@conform-to/zod"; -import { ComputerDesktopIcon, MoonIcon, SunIcon, SwatchIcon } from "@heroicons/react/20/solid"; import { Form, useActionData, useFetcher, useLoaderData } from "@remix-run/react"; import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; +import { ThemeSegmentedControl } from "~/components/ThemeSegmentedControl"; import { UserProfilePhoto } from "~/components/UserProfilePhoto"; import { MainHorizontallyCenteredContainer, @@ -12,7 +12,6 @@ import { PageContainer, } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; -import { Select, SelectItem } from "~/components/primitives/Select"; import { Slider } from "~/components/primitives/Slider"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; @@ -40,36 +39,6 @@ import { emailSchema, MAX_EMAIL_LENGTH } from "~/utils/emailValidation"; import { accountPath } from "~/utils/pathBuilder"; import { pageMeta } from "~/utils/pageTitle"; -const THEME_LABELS: Record = { - classic: "Classic", - system: "System preference", - dark: "Dark", - light: "Light", -}; - -function themeLabel(value: ThemePreference) { - return THEME_LABELS[value]; -} - -function themeIcon(value: ThemePreference) { - switch (value) { - case "classic": - return ; - case "system": - return ; - case "dark": - // Moon glyph reads small at its natural size, so nudge it up inside a - // size-4 box to line up with the other icons. - return ( - - - - ); - case "light": - return ; - } -} - export const meta = pageMeta("Your profile"); function createSchema( @@ -303,67 +272,62 @@ export default function Page() { {showThemeSwitcher && ( <> -
+
Appearance
-
- - - aria-label="Interface theme" - value={theme} - setValue={(value) => - themeFetcher.submit( - { action: "update-theme", theme: value }, - { method: "post" } - ) - } - variant="secondary/small" - dropdownIcon - items={["classic", "system", "dark", "light"]} - text={(value) => ( - - {themeIcon(value)} - {themeLabel(value)} - - )} - className="w-44" - > - {(items) => - items.map((item) => ( - - {themeLabel(item)} - - )) - } - +
+
+ + + +
+ + themeFetcher.submit( + { action: "update-theme", theme: value }, + { method: "post" } + ) + } + /> +
+
{theme !== "classic" && ( -
- - { - // Live preview before the preference persists - const value = values[0] ?? 0; - setContrastPreview(value); - document.documentElement.style.setProperty( - "--theme-contrast", - String(value / 100) - ); - }} - onValueCommit={(values) => - contrastFetcher.submit( - { action: "update-contrast", contrast: String(values[0] ?? 0) }, - { method: "post" } - ) - } - /> +
+
+ + + +
+ { + // Live preview before the preference persists + const value = values[0] ?? 0; + setContrastPreview(value); + document.documentElement.style.setProperty( + "--theme-contrast", + String(value / 100) + ); + }} + onValueCommit={(values) => + contrastFetcher.submit( + { action: "update-contrast", contrast: String(values[0] ?? 0) }, + { method: "post" } + ) + } + /> +
+
)} diff --git a/apps/webapp/app/routes/resources.preferences.theme.ts b/apps/webapp/app/routes/resources.preferences.theme.ts new file mode 100644 index 00000000000..901201c5497 --- /dev/null +++ b/apps/webapp/app/routes/resources.preferences.theme.ts @@ -0,0 +1,29 @@ +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import { updateThemePreference } from "~/services/dashboardPreferences.server"; +import { requireUser } from "~/services/session.server"; +import { ThemePreference } from "~/utils/themePreference"; +import { cachedFlag } from "~/v3/featureFlags.server"; + +export async function action({ request }: ActionFunctionArgs) { + const user = await requireUser(request); + + // Same gate as the account page: while the flag is off, everyone stays on the + // classic theme, so a preference must not be writable from the menu either. + const showThemeSwitcher = + user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })); + if (!showThemeSwitcher) { + return json({ success: false, error: "Not available" }, { status: 404 }); + } + + const formData = await request.formData(); + // Parsed strictly rather than normalized: an unknown value should fail loudly + // instead of silently resetting the user's theme to the default. + const theme = ThemePreference.safeParse(formData.get("theme")); + if (!theme.success) { + return json({ success: false, error: "Invalid theme" }, { status: 400 }); + } + + await updateThemePreference({ user, theme: theme.data }); + + return json({ success: true }); +} From 54939feafb92f09aaf27a686b8819e7eb301c5c2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 08:45:22 +0000 Subject: [PATCH 02/25] 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) --- .../appearance-toggle-in-account-menu.md | 6 -- .../app/components/ThemeSegmentedControl.tsx | 60 ------------------- .../navigation/AppearanceMenuItem.tsx | 11 ++++ .../app/components/primitives/Slider.tsx | 43 ++++++++++++- apps/webapp/app/components/themeOptions.ts | 7 +++ .../app/routes/account._index/route.tsx | 38 +++++++++--- 6 files changed, 89 insertions(+), 76 deletions(-) delete mode 100644 .server-changes/appearance-toggle-in-account-menu.md delete mode 100644 apps/webapp/app/components/ThemeSegmentedControl.tsx diff --git a/.server-changes/appearance-toggle-in-account-menu.md b/.server-changes/appearance-toggle-in-account-menu.md deleted file mode 100644 index 59e268103a3..00000000000 --- a/.server-changes/appearance-toggle-in-account-menu.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -Switch between the System, Light and Dark themes from the new Appearance menu in your account menu, without having to open your profile settings. diff --git a/apps/webapp/app/components/ThemeSegmentedControl.tsx b/apps/webapp/app/components/ThemeSegmentedControl.tsx deleted file mode 100644 index 7e83362f65d..00000000000 --- a/apps/webapp/app/components/ThemeSegmentedControl.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import SegmentedControl from "~/components/primitives/SegmentedControl"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { CLASSIC_OPTION, THEME_OPTIONS, type ThemeOption } from "~/components/themeOptions"; -import { type ThemePreference } from "~/utils/themePreference"; - -/** - * Icon-only segmented control for picking a theme. Every segment is labelled by - * a tooltip and screen-reader text. - * - * `name` must be unique per mounted instance: the underlying control animates - * its selection with a shared `layoutId` derived from it, so two instances with - * the same name would fight over one indicator. - */ -export function ThemeSegmentedControl({ - name, - value, - onChange, - includeClassic = false, -}: { - name: string; - /** A value outside the offered segments (e.g. `classic` when it isn't - * included) simply leaves the control with nothing selected. */ - value: ThemePreference; - onChange: (theme: ThemePreference) => void; - includeClassic?: boolean; -}) { - const segments = includeClassic ? [...THEME_OPTIONS, CLASSIC_OPTION] : THEME_OPTIONS; - - return ( - onChange(theme as ThemePreference)} - options={segments.map((segment) => ({ - value: segment.value, - label: , - }))} - /> - ); -} - -function ThemeSegmentLabel({ segment }: { segment: ThemeOption }) { - return ( - - - {segment.label} - - } - content={segment.label} - className="px-2 py-1.5 text-xs" - sideOffset={6} - disableHoverableContent - /> - ); -} diff --git a/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx index 97f993defee..906c70ed6af 100644 --- a/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx +++ b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx @@ -1,9 +1,11 @@ +import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; import { useFetcher } from "@remix-run/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 { type loader as rootLoader } from "~/root"; +import { accountPath } from "~/utils/pathBuilder"; import { normalizeThemePreference } from "~/utils/themePreference"; import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu"; import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes"; @@ -50,6 +52,15 @@ export function AppearanceMenuItem() { /> ))}
+
+ +
); } diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index e3ab6cdcd4e..e74babeb231 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -1,5 +1,5 @@ import * as RadixSlider from "@radix-ui/react-slider"; -import type { ComponentProps } from "react"; +import { type ComponentProps, useState } from "react"; import { cn } from "~/utils/cn"; import type { RenderIcon } from "./Icon"; import { Icon } from "./Icon"; @@ -34,6 +34,12 @@ export type SliderProps = ComponentProps & { LeadingIcon?: RenderIcon; TrailingIcon?: RenderIcon; variant: VariantName; + /** + * Opts into a small label above the thumb showing the formatted value, while + * hovering, dragging, or focused via the keyboard. It sits inside the thumb, + * so it tracks the handle exactly. Reads the controlled `value`, so pass one. + */ + valueTooltip?: (value: number) => string; }; export function Slider({ @@ -42,9 +48,15 @@ export function Slider({ LeadingIcon, TrailingIcon, "aria-label": ariaLabel, + valueTooltip, ...props }: SliderProps) { const variation = variants[variant]; + // The pointer leaves the thumb while dragging, so hover alone can't keep the + // label up. + const [isDragging, setIsDragging] = useState(false); + const currentValue = props.value?.[0] ?? props.defaultValue?.[0] ?? 0; + return (
{LeadingIcon && } @@ -55,6 +67,18 @@ export function Slider({ className )} {...props} + onPointerDown={(event) => { + props.onPointerDown?.(event); + setIsDragging(true); + }} + onPointerUp={(event) => { + props.onPointerUp?.(event); + setIsDragging(false); + }} + onPointerCancel={(event) => { + props.onPointerCancel?.(event); + setIsDragging(false); + }} > @@ -63,10 +87,23 @@ export function Slider({ + > + {valueTooltip && ( + + {valueTooltip(currentValue)} + + )} + {TrailingIcon && }
diff --git a/apps/webapp/app/components/themeOptions.ts b/apps/webapp/app/components/themeOptions.ts index ff25dc01852..53921f7d66e 100644 --- a/apps/webapp/app/components/themeOptions.ts +++ b/apps/webapp/app/components/themeOptions.ts @@ -24,3 +24,10 @@ export const CLASSIC_OPTION: ThemeOption = { label: "Classic", icon: SwatchIcon, }; + +/** Every theme, for the account page's full picker. */ +export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, CLASSIC_OPTION]; + +export const THEME_OPTIONS_BY_VALUE = Object.fromEntries( + ALL_THEME_OPTIONS.map((option) => [option.value, option]) +) as Record; diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index e31ea10ad0c..572ae1cc5cf 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -4,7 +4,6 @@ import { conformZodMessage, parseWithZod } from "@conform-to/zod"; import { Form, useActionData, useFetcher, useLoaderData } from "@remix-run/react"; import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; -import { ThemeSegmentedControl } from "~/components/ThemeSegmentedControl"; import { UserProfilePhoto } from "~/components/UserProfilePhoto"; import { MainHorizontallyCenteredContainer, @@ -12,6 +11,7 @@ import { PageContainer, } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { Slider } from "~/components/primitives/Slider"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; @@ -20,6 +20,7 @@ import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Switch } from "~/components/primitives/Switch"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { ALL_THEME_OPTIONS, THEME_OPTIONS_BY_VALUE } from "~/components/themeOptions"; import { prisma } from "~/db.server"; import { useUser } from "~/hooks/useUser"; import { redirectWithSuccessMessage } from "~/models/message.server"; @@ -41,6 +42,11 @@ import { pageMeta } from "~/utils/pageTitle"; export const meta = pageMeta("Your profile"); +function themeIcon(value: ThemePreference) { + const Icon = THEME_OPTIONS_BY_VALUE[value].icon; + return ; +} + function createSchema( constraints: { isEmailUnique?: (email: string) => Promise; @@ -281,17 +287,34 @@ export default function Page() {
- + aria-label="Theme" value={theme} - includeClassic - onChange={(value) => + setValue={(value) => themeFetcher.submit( { action: "update-theme", theme: value }, { method: "post" } ) } - /> + variant="secondary/small" + dropdownIcon + items={ALL_THEME_OPTIONS.map((option) => option.value)} + text={(value) => ( + + {themeIcon(value)} + {THEME_OPTIONS_BY_VALUE[value].label} + + )} + className="w-44" + > + {(items) => + items.map((item) => ( + + {THEME_OPTIONS_BY_VALUE[item].label} + + )) + } +
@@ -308,7 +331,8 @@ export default function Page() { aria-label="Contrast" min={0} max={100} - step={5} + step={1} + valueTooltip={(value) => `${value}%`} value={[contrastPreview]} onValueChange={(values) => { // Live preview before the preference persists From 41a97894567719eded3665d8c06e5a0bac141771 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 09:06:02 +0000 Subject: [PATCH 03/25] fix(webapp): stop the contrast label sticking, tighten the theme picker The value label above the contrast handle is now purely hover-driven: it was also keyed off focus, which the thumb keeps after a click, leaving the label stuck on once clicked. The handle itself grows from 12px to 18px. Also halves the profile page theme picker and its popover, which was held open by a 180px floor, brightens its icons and labels, and widens the Appearance submenu to 144px. Co-Authored-By: Claude Opus 5 (1M context) --- .../navigation/AppearanceMenuItem.tsx | 4 ++-- .../app/components/primitives/Slider.tsx | 18 +++++++++--------- .../webapp/app/routes/account._index/route.tsx | 14 +++++++++++--- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx index 906c70ed6af..214b9c04e3b 100644 --- a/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx +++ b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx @@ -35,8 +35,8 @@ export function AppearanceMenuItem() { : rootData.themePreference; return ( - // Half the standard submenu width: three short labels don't need the room. - + // Much narrower than the standard submenu: these labels don't need the room. +
{THEME_OPTIONS.map((option) => ( & { TrailingIcon?: RenderIcon; variant: VariantName; /** - * Opts into a small label above the thumb showing the formatted value, while - * hovering, dragging, or focused via the keyboard. It sits inside the thumb, - * so it tracks the handle exactly. Reads the controlled `value`, so pass one. + * Opts into a small label above the thumb showing the formatted value, shown + * while the thumb is hovered or being dragged. It sits inside the thumb, so it + * tracks the handle exactly. Reads the controlled `value`, so pass one. */ valueTooltip?: (value: number) => string; }; @@ -95,9 +95,9 @@ export function Slider({ {valueTooltip(currentValue)} diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 572ae1cc5cf..ffe0b385f62 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -44,7 +44,7 @@ export const meta = pageMeta("Your profile"); function themeIcon(value: ThemePreference) { const Icon = THEME_OPTIONS_BY_VALUE[value].icon; - return ; + return ; } function createSchema( @@ -305,11 +305,19 @@ export default function Page() { {THEME_OPTIONS_BY_VALUE[value].label} )} - className="w-44" + className="w-22" + // The popover's 180px floor left a gap past the longest + // label; track the trigger's width instead. + popoverClassName="min-w-22" > {(items) => items.map((item) => ( - + {THEME_OPTIONS_BY_VALUE[item].label} )) From 47d3246c66e0192fa0248ddb4d036a30cc229a25 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 11:25:18 +0000 Subject: [PATCH 04/25] feat(webapp): mark the default contrast on the slider Ticks the track at 20% and swaps the handle's label from a percentage to "Default" when it lands there. The tick uses Radix's own thumb-offset formula so it sits exactly under the handle's centre rather than a few pixels off. The label also gains an arrow pointing down at the handle. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/components/primitives/Slider.tsx | 29 ++++++++++++++++++- .../app/routes/account._index/route.tsx | 9 +++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index 8aa7e1e0175..58a62ec5b23 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -16,6 +16,7 @@ const variants = { // light track thumb: "h-4.5 w-4.5 border border-border-bright bg-white shadow-sm dark:border-transparent dark:bg-charcoal-200 dark:shadow-none", + thumbSize: 18, }, tertiary: { container: "h-6 gap-1 rounded-sm hover:bg-background-raised px-1", @@ -25,6 +26,7 @@ const variants = { range: "bg-transparent group-hover:bg-secondary", thumb: "h-3 w-3 border-2 border-text-dimmed bg-grid-bright shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)] hover:border-text-dimmed focus:shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)]", + thumbSize: 12, }, }; @@ -40,6 +42,8 @@ export type SliderProps = ComponentProps & { * tracks the handle exactly. Reads the controlled `value`, so pass one. */ valueTooltip?: (value: number) => string; + /** Values to tick on the track, e.g. the setting's default. */ + marks?: number[]; }; export function Slider({ @@ -49,6 +53,7 @@ export function Slider({ TrailingIcon, "aria-label": ariaLabel, valueTooltip, + marks, ...props }: SliderProps) { const variation = variants[variant]; @@ -56,6 +61,8 @@ export function Slider({ // label up. const [isDragging, setIsDragging] = useState(false); const currentValue = props.value?.[0] ?? props.defaultValue?.[0] ?? 0; + const min = props.min ?? 0; + const max = props.max ?? 100; return (
@@ -83,6 +90,23 @@ export function Slider({ + {marks?.map((mark) => { + const percent = ((mark - min) / (max - min)) * 100; + if (!Number.isFinite(percent) || percent < 0 || percent > 100) return null; + // Radix keeps the thumb inside the track by offsetting it against its + // own width, so a plain percentage would sit off the handle. Same + // formula as its `getThumbInBoundsOffset`, so the tick lands under + // the thumb's centre. + const offset = variation.thumbSize * (0.5 - percent / 100); + return ( + + ); + })} {/* The thumb is the role="slider" element, so the label lives here */} {valueTooltip(currentValue)} + {/* Straddles the bottom edge, hiding the border it overlaps, so the + two outer sides read as an arrow pointing at the handle. */} + )} diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index ffe0b385f62..edb54fc7b19 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -42,6 +42,10 @@ import { pageMeta } from "~/utils/pageTitle"; export const meta = pageMeta("Your profile"); +/** The contrast the slider ticks and labels as "Default". Note this is not the + * same as `DEFAULT_THEME_CONTRAST`, the value applied when none is saved. */ +const DEFAULT_CONTRAST_MARK = 20; + function themeIcon(value: ThemePreference) { const Icon = THEME_OPTIONS_BY_VALUE[value].icon; return ; @@ -340,7 +344,10 @@ export default function Page() { min={0} max={100} step={1} - valueTooltip={(value) => `${value}%`} + marks={[DEFAULT_CONTRAST_MARK]} + valueTooltip={(value) => + value === DEFAULT_CONTRAST_MARK ? "Default" : `${value}%` + } value={[contrastPreview]} onValueChange={(values) => { // Live preview before the preference persists From 4d5645dc85dd1e21abf5e5aedd4407e947ba45cc Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 11:39:46 +0000 Subject: [PATCH 05/25] fix(webapp): widen the theme select and correct the tooltip arrow The trigger is now sized to the widest option, and its icon can no longer shrink - at the old width "System" and "Classic" squeezed it down to a sliver. The popover matches that width. The contrast label's arrow had its borders on the two left-hand edges rather than the two facing the handle; rotating a square clockwise sends the bottom and right edges downward, not the bottom and left. Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/components/primitives/Slider.tsx | 2 +- apps/webapp/app/routes/account._index/route.tsx | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index 58a62ec5b23..a8ec398aafc 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -127,7 +127,7 @@ export function Slider({ {valueTooltip(currentValue)} {/* Straddles the bottom edge, hiding the border it overlaps, so the two outer sides read as an arrow pointing at the handle. */} - + )} diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index edb54fc7b19..d2ba71457d0 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -48,7 +48,9 @@ const DEFAULT_CONTRAST_MARK = 20; function themeIcon(value: ThemePreference) { const Icon = THEME_OPTIONS_BY_VALUE[value].icon; - return ; + // shrink-0: without it the icon is the flex item that gives way to a long + // label, and "System"/"Classic" squash it to a sliver. + return ; } function createSchema( @@ -309,10 +311,12 @@ export default function Page() { {THEME_OPTIONS_BY_VALUE[value].label} )} - className="w-22" + // Sized to the widest option (Classic, 106px) so no label + // squeezes its icon, rounded up to the nearest step. + className="w-27" // The popover's 180px floor left a gap past the longest - // label; track the trigger's width instead. - popoverClassName="min-w-22" + // label; match the trigger instead. + popoverClassName="min-w-27" > {(items) => items.map((item) => ( From 4c6ddfbf1cbaff28e38d8be1c4c92c8409dd59b0 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 12:13:39 +0000 Subject: [PATCH 06/25] fix(webapp): notch the contrast track at the default mark Doubles the mark's height and hangs background-coloured borders either side of the 1px line, so it cuts through the track instead of blending into it. The border colour is a theme token, so it follows light mode too. Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/components/primitives/Slider.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index a8ec398aafc..7833733b12d 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -102,7 +102,10 @@ export function Slider({ ); From 5c9de3d366d5b3cfd1b973cfa8e5415a29d214d8 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 12:24:31 +0000 Subject: [PATCH 07/25] fix(webapp): notch the contrast mark against the real page colour The mark's gap borders used background-bright, which is not what sits behind a settings row - the page there is background-dimmed, so the "gap" rendered as a slightly lighter stripe instead. Both colours now come from the slider variant, the line matches the track, and its ends are rounded. Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/components/primitives/Slider.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index 7833733b12d..3fb13a515f1 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -17,6 +17,9 @@ const variants = { thumb: "h-4.5 w-4.5 border border-border-bright bg-white shadow-sm dark:border-transparent dark:bg-charcoal-200 dark:shadow-none", thumbSize: 18, + // Track-coloured line, notched off the track by borders in the colour of + // the page behind it (settings rows sit on background-dimmed). + mark: "bg-grid-bright border-background-dimmed", }, tertiary: { container: "h-6 gap-1 rounded-sm hover:bg-background-raised px-1", @@ -27,6 +30,7 @@ const variants = { thumb: "h-3 w-3 border-2 border-text-dimmed bg-grid-bright shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)] hover:border-text-dimmed focus:shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)]", thumbSize: 12, + mark: "bg-grid-bright border-background-dimmed", }, }; @@ -102,10 +106,13 @@ export function Slider({ ); From 2a1e2912a23582f0ec3f5e13c5ee0d86ac9bc414 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 12:29:08 +0000 Subject: [PATCH 08/25] fix(webapp): widen the gap either side of the contrast mark Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/components/primitives/Slider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index 3fb13a515f1..f54a3d04676 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -110,7 +110,7 @@ export function Slider({ // keeps the line 1px wide and hangs the borders outside it, so // they read as a gap in the track rather than eating the line. className={cn( - "absolute top-1/2 box-content h-4 w-px -translate-x-1/2 -translate-y-1/2 rounded-full border-x", + "absolute top-1/2 box-content h-4 w-px -translate-x-1/2 -translate-y-1/2 rounded-full border-x-[3px]", variation.mark )} style={{ left: `calc(${percent}% + ${offset}px)` }} From 74802cfaa1fe214a9e3de5d3f1ac4e46002b5682 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 12:32:44 +0000 Subject: [PATCH 09/25] fix(webapp): thicken the contrast mark line to 2px Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/components/primitives/Slider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index f54a3d04676..a11af156a03 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -110,7 +110,7 @@ export function Slider({ // keeps the line 1px wide and hangs the borders outside it, so // they read as a gap in the track rather than eating the line. className={cn( - "absolute top-1/2 box-content h-4 w-px -translate-x-1/2 -translate-y-1/2 rounded-full border-x-[3px]", + "absolute top-1/2 box-content h-4 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-x-[3px]", variation.mark )} style={{ left: `calc(${percent}% + ${offset}px)` }} From e0a58caaafd5e9079aabd1232431149a26e8a138 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 12:43:51 +0000 Subject: [PATCH 10/25] feat(webapp): click the contrast mark to reset to the default The mark on the track becomes a button labelled "Reset to default" that snaps the handle back to 20% and saves, and brightens on hover. It stops its own pointer events reaching the track, which would otherwise drag the handle to the press instead. The handle still wins wherever the two overlap: the mark is drawn before the thumb, so the thumb takes the hover, the click and its own label. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/components/primitives/Slider.tsx | 61 +++++++++++++++---- .../app/routes/account._index/route.tsx | 40 +++++++----- 2 files changed, 72 insertions(+), 29 deletions(-) diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index a11af156a03..70a52f5d884 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -3,6 +3,7 @@ import { type ComponentProps, useState } from "react"; import { cn } from "~/utils/cn"; import type { RenderIcon } from "./Icon"; import { Icon } from "./Icon"; +import { SimpleTooltip } from "./Tooltip"; const variants = { /* Quiet variant for settings rows: no hover box, no thumb halo */ @@ -20,6 +21,7 @@ const variants = { // Track-coloured line, notched off the track by borders in the colour of // the page behind it (settings rows sit on background-dimmed). mark: "bg-grid-bright border-background-dimmed", + markHover: "hover:bg-text-dimmed", }, tertiary: { container: "h-6 gap-1 rounded-sm hover:bg-background-raised px-1", @@ -31,6 +33,7 @@ const variants = { "h-3 w-3 border-2 border-text-dimmed bg-grid-bright shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)] hover:border-text-dimmed focus:shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)]", thumbSize: 12, mark: "bg-grid-bright border-background-dimmed", + markHover: "hover:bg-text-dimmed", }, }; @@ -47,7 +50,15 @@ export type SliderProps = ComponentProps & { */ valueTooltip?: (value: number) => string; /** Values to tick on the track, e.g. the setting's default. */ - marks?: number[]; + marks?: SliderMark[]; +}; + +export type SliderMark = { + value: number; + /** Tooltip on hover, and the accessible name once `onSelect` is set. */ + label?: string; + /** Makes the mark a button, e.g. to reset the setting to its default. */ + onSelect?: () => void; }; export function Slider({ @@ -95,25 +106,49 @@ export function Slider({ {marks?.map((mark) => { - const percent = ((mark - min) / (max - min)) * 100; + const percent = ((mark.value - min) / (max - min)) * 100; if (!Number.isFinite(percent) || percent < 0 || percent > 100) return null; // Radix keeps the thumb inside the track by offsetting it against its // own width, so a plain percentage would sit off the handle. Same // formula as its `getThumbInBoundsOffset`, so the tick lands under // the thumb's centre. const offset = variation.thumbSize * (0.5 - percent / 100); + const style = { left: `calc(${percent}% + ${offset}px)` }; + // Drawn after the track, so it sits on top of it - and before the + // thumb, so an overlapping handle keeps the hover and the click. + // box-content keeps the line 2px wide and hangs the borders outside + // it, so they read as a gap in the track rather than eating the line. + const markClassName = cn( + "absolute top-1/2 box-content h-4 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-x-[3px]", + variation.mark + ); + + if (!mark.onSelect) { + return ; + } + return ( - event.stopPropagation()} + onClick={mark.onSelect} + /> + } /> ); })} diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index d2ba71457d0..84dae062ebd 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -198,6 +198,18 @@ export default function Page() { } }, [contrastFetcher.state, contrast]); + // Dragging previews through the CSS var; releasing (or clicking the default + // mark) persists it. + const previewContrast = (value: number) => { + setContrastPreview(value); + document.documentElement.style.setProperty("--theme-contrast", String(value / 100)); + }; + const saveContrast = (value: number) => + contrastFetcher.submit( + { action: "update-contrast", contrast: String(value) }, + { method: "post" } + ); + const [form, { name, email, marketingEmails }] = useForm({ id: "account", // TODO: type this @@ -348,26 +360,22 @@ export default function Page() { min={0} max={100} step={1} - marks={[DEFAULT_CONTRAST_MARK]} + marks={[ + { + value: DEFAULT_CONTRAST_MARK, + label: "Reset to default", + onSelect: () => { + previewContrast(DEFAULT_CONTRAST_MARK); + saveContrast(DEFAULT_CONTRAST_MARK); + }, + }, + ]} valueTooltip={(value) => value === DEFAULT_CONTRAST_MARK ? "Default" : `${value}%` } value={[contrastPreview]} - onValueChange={(values) => { - // Live preview before the preference persists - const value = values[0] ?? 0; - setContrastPreview(value); - document.documentElement.style.setProperty( - "--theme-contrast", - String(value / 100) - ); - }} - onValueCommit={(values) => - contrastFetcher.submit( - { action: "update-contrast", contrast: String(values[0] ?? 0) }, - { method: "post" } - ) - } + onValueChange={(values) => previewContrast(values[0] ?? 0)} + onValueCommit={(values) => saveContrast(values[0] ?? 0)} />
From 0aeee45a33adfd1cf1f89e97b7fda4c1dc7ba46f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 18:11:20 +0000 Subject: [PATCH 11/25] feat(webapp): split icon and badge accents out into an Icon contrast toggle The high-contrast accent set - solid status badges, monochrome nav icons and the darker, unified icon colours - was tied to the Dark and Light themes. It moves to its own preference, off by default, so any theme can run either Classic's colours or the high-contrast ones. Comparing the two sets turned up 22 re-mapped tokens: three change hue outright (preview and preview branches yellow to blue, queue charts purple to blue) and the rest step down in lightness for white. The `system:` variant that carries the badge restyle across ~50 components now keys off the preference too. Classic's accents were drawn for dark cards and most sit under 3:1 on white, so the Light theme gets its own Classic-derived set: same hues, stepped down in lightness only, so yellows stay yellow and queues stay purple. Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/root.tsx | 10 ++ .../app/routes/account._index/route.tsx | 46 ++++++++ .../services/dashboardPreferences.server.ts | 30 +++++ apps/webapp/app/tailwind.css | 105 +++++++++++++----- apps/webapp/app/utils/dashboardPreferences.ts | 2 + apps/webapp/app/utils/themePreference.ts | 6 + 6 files changed, 172 insertions(+), 27 deletions(-) diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 4e4fd1a90db..8691a7e4a53 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -24,6 +24,7 @@ import { useSystemThemeSync } from "./hooks/useSystemThemeSync"; import { getImpersonationState } from "./services/impersonation.server"; import { getUser } from "./services/session.server"; import { + normalizeIconContrast, normalizeThemeContrast, normalizeThemePreference, type ThemePreference, @@ -94,6 +95,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const themeContrast = showThemeSwitcher ? normalizeThemeContrast(user?.dashboardPreferences.contrast) : 0; + // Icon and badge accents. Off by default, and forced off with the switcher + // hidden so logged-out and unflagged pages render the Classic set. + const iconContrast = showThemeSwitcher + ? normalizeIconContrast(user?.dashboardPreferences.iconContrast) + : false; // Display-only: while impersonating, an admin can ask to see the dashboard // the way the impersonated user sees it. Exposed from root so every route can // read it. @@ -123,6 +129,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { kapa, timezone, showThemeSwitcher, + iconContrast, themePreference, themeContrast, // Consumed by ResizablePanel: the browser check must match between SSR @@ -176,6 +183,7 @@ export default function App() { kapa: _kapa, themePreference, themeContrast, + iconContrast, } = useTypedLoaderData(); usePostHog(posthogProjectKey, posthogUiHost); useSystemThemeSync(themePreference); @@ -192,6 +200,8 @@ export default function App() { suppressHydrationWarning data-theme={resolvedTheme} data-theme-preference={themePreference} + // Accent set for icons and badges; the `system:` variant keys off this + data-icon-contrast={iconContrast ? "true" : "false"} // Contrast overlay input for the System themes; Classic never reads it style={{ "--theme-contrast": themeContrast / 100 } as CSSProperties} > diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 84dae062ebd..40fb830c97f 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -27,9 +27,11 @@ import { redirectWithSuccessMessage } from "~/models/message.server"; import { updateUser } from "~/models/user.server"; import { updateContrastPreference, + updateIconContrastPreference, updateThemePreference, } from "~/services/dashboardPreferences.server"; import { + normalizeIconContrast, normalizeThemeContrast, normalizeThemePreference, type ThemePreference, @@ -126,6 +128,20 @@ export const action: ActionFunction = async ({ request }) => { return json({ success: true }); } + if (formData.get("action") === "update-icon-contrast") { + const user = await requireUser(request); + const showThemeSwitcher = + user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })); + if (!showThemeSwitcher) { + return json({ error: "Not available" }, { status: 404 }); + } + await updateIconContrastPreference({ + user, + iconContrast: formData.get("iconContrast") === "true", + }); + return json({ success: true }); + } + const formSchema = createSchema({ isEmailUnique: async (email) => { const existingUser = await prisma.user.findFirst({ @@ -176,6 +192,12 @@ export default function Page() { const lastSubmission = useActionData(); const themeFetcher = useFetcher(); const contrastFetcher = useFetcher(); + const iconContrastFetcher = useFetcher(); + const pendingIconContrast = iconContrastFetcher.formData?.get("iconContrast"); + const iconContrast = + typeof pendingIconContrast === "string" + ? pendingIconContrast === "true" + : normalizeIconContrast(user.dashboardPreferences.iconContrast); const pendingTheme = themeFetcher.formData?.get("theme"); const pendingContrast = contrastFetcher.formData?.get("contrast"); const contrast = @@ -381,6 +403,30 @@ export default function Page() { )} +
+
+ + + +
+ + iconContrastFetcher.submit( + { + action: "update-icon-contrast", + iconContrast: checked ? "true" : "false", + }, + { method: "post" } + ) + } + className="w-fit" + /> +
+
+
)} diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 772ba338ac6..986a39fd4c7 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -178,6 +178,36 @@ export async function updateContrastPreference({ `; } +export async function updateIconContrastPreference({ + user, + iconContrast, +}: { + user: UserFromSession; + iconContrast: boolean; +}) { + if (user.isImpersonating) { + return; + } + + if ((user.dashboardPreferences.iconContrast ?? false) === iconContrast) { + return; + } + + // Narrow jsonb_set write: see updateThemePreference. + return prisma.$executeRaw` + UPDATE "User" + SET "dashboardPreferences" = jsonb_set( + COALESCE( + "dashboardPreferences", + '{"version":"1","projects":{}}'::jsonb + ), + '{iconContrast}', + to_jsonb(${iconContrast}::boolean) + ) + WHERE id = ${user.id} + `; +} + export async function clearCurrentProject({ user }: { user: UserFromSession }) { if (user.isImpersonating) { return; diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 174a9a9ff5e..ba68bc2afcd 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -231,15 +231,18 @@ */ /* - System preference unified accents (dark+light); Classic keeps the original - set. One shared value per accent token, >=3:1 against both dark cards and - white. Accents whose Classic default already clears both modes (blue-500, - indigo-500, pink-500, purple-500, red-500, violet-500, fuchsia-500, - rose-600) are not repeated here. Text-sized tokens (text-link, callout - text) stay per-mode: no color reaches 4.5:1 on both #1a1b1f and #ffffff. - Dark derives everything else (monochrome surfaces etc.) from Classic. + High-contrast accents, opt-in via the "Icon contrast" preference + (data-icon-contrast on ), independent of the theme. One shared value + per accent token, >=3:1 against both dark cards and white. Accents whose + Classic default already clears both modes (blue-500, indigo-500, pink-500, + purple-500, red-500, violet-500, fuchsia-500, rose-600) are not repeated + here. Text-sized tokens (text-link, callout text) stay per-mode: no color + reaches 4.5:1 on both #1a1b1f and #ffffff. + + With the preference off, every theme keeps Classic's accents; the Light + theme darkens them for white further down, holding Classic's hues. */ -:is([data-theme="dark"], [data-theme="light"]) { +[data-icon-contrast="true"] { /* Status */ --color-success: var(--color-mint-600); --color-warning: var(--color-amber-600); @@ -279,25 +282,26 @@ --color-run-timed-out: #ed5f74; } -/* System themes drop decorative icon accents to monochrome; Classic keeps the - colored icons. side-menu-active-icon is set in SideMenuItem for the active - nav item; system-mono-icon marks section-header icons (e.g. the limits page). */ -:is([data-theme="dark"], [data-theme="light"]) :is(.side-menu-active-icon, .system-mono-icon) { +/* Icon contrast drops decorative icon accents to monochrome; with it off, + the icons stay colored. side-menu-active-icon is set in SideMenuItem for the + active nav item; system-mono-icon marks section-header icons (e.g. the + limits page). */ +[data-icon-contrast="true"] :is(.side-menu-active-icon, .system-mono-icon) { color: var(--color-text-bright); } -/* System themes: status/env labels follow the surrounding text color, only the - icon keeps its tint. Classic colors both. Set in EnvironmentLabel and the - status combo components. */ -:is([data-theme="dark"], [data-theme="light"]) .system-mono-label { +/* Icon contrast: status/env labels follow the surrounding text color, only the + icon keeps its tint. With it off both are colored. Set in EnvironmentLabel + and the status combo components. */ +[data-icon-contrast="true"] .system-mono-label { color: inherit; } /* Tinted status chips respond to the contrast control: a ring in the chip's own text color fades in as contrast rises, so the soft tint keeps its - footprint but the chip gains definition. Transparent at contrast 0; - Classic never sets the variable. Marker set by the chip components. */ -:is([data-theme="dark"], [data-theme="light"]) .contrast-chip { + footprint but the chip gains definition. Transparent at contrast 0; only + set under icon contrast. Marker set by the chip components. */ +[data-icon-contrast="true"] .contrast-chip { box-shadow: inset 0 0 0 1px color-mix(in srgb, currentcolor calc(var(--theme-contrast, 0) * 70%), transparent); } @@ -424,9 +428,11 @@ Classic and Dark are both dark-mode themes, so the variant matches both. */ @custom-variant dark (&:where([data-theme="dark"], [data-theme="classic"], [data-theme="dark"] *, [data-theme="classic"] *)); -/* system: matches the System preference themes (Dark and Light) but never - Classic - for restyles that must leave Classic untouched. */ -@custom-variant system (&:where([data-theme="dark"], [data-theme="light"], [data-theme="dark"] *, [data-theme="light"] *)); +/* system: the high-contrast icon and badge treatment - solid status badges, + monochrome nav icons. Named for the System themes it shipped with, but it now + follows the "Icon contrast" preference in any theme; off means Classic's + tinted chips and colored icons. */ +@custom-variant system (&:where([data-icon-contrast="true"], [data-icon-contrast="true"] *)); /* light: the Light theme only - for values that are fine on every dark theme but illegible on white. Classic is never matched. */ @@ -789,8 +795,9 @@ --color-border-brighter: #b9bdc7; --color-border-brightest: #9ba1ad; - /* Status/env/icon accents live in the unified dark+light block above; - dev keeps its Classic pink-500, which passes on white too. */ + /* Accents come from one of two sets: the high-contrast block above when Icon + contrast is on, or the Classic-derived block below when it's off. Either + way dev keeps its Classic pink-500, which passes on white too. */ /* Staging is the one per-mode env color: warm orange has no clean unified mid-tone, so dark keeps Classic's bright orange-400 and light deepens it */ @@ -877,14 +884,58 @@ --color-editor-scrollbar-thumb-active: #aeb3be; } +/* + Light theme with Icon contrast off: Classic's accents were drawn for dark + cards and most sit under 3:1 on white (preview 1.57, warning 2.13, metrics + 2.22). This is the same palette read for a white page - each token keeps + Classic's hue and is only stepped down in lightness, so yellows stay yellow + and queues stay purple rather than moving to the blue the high-contrast set + uses. Every Classic accent that already clears white (dev, tasks, runs, + batches, logs, alerts...) is left alone. +*/ +[data-theme="light"]:not([data-icon-contrast="true"]) { + /* Status */ + --color-success: var(--color-mint-600); + --color-warning: var(--color-amber-600); + + /* Environments */ + --color-prod: var(--color-mint-600); + --color-preview: var(--color-yellow-700); + + /* Icons */ + --color-schedules: var(--color-yellow-700); + --color-previewBranches: var(--color-yellow-700); + --color-metrics: var(--color-green-600); + --color-regions: var(--color-green-600); + --color-aiMetrics: var(--color-green-600); + --color-bulkActions: var(--color-emerald-600); + --color-concurrency: var(--color-amber-600); + --color-errors: var(--color-amber-600); + --color-apiKeys: var(--color-amber-600); + + /* Queue charts keep Classic's purple, one step down for white */ + --color-queues-chart: var(--color-purple-600); + + /* Callout accents */ + --color-callout-docs: var(--color-blue-500); + --color-callout-pending: var(--color-blue-500); + --color-callout-pricing: var(--color-indigo-500); + + /* Amber run statuses, Classic's dark-to-light order kept */ + --color-run-waiting-for-deploy: var(--color-amber-700); + --color-run-pending-version: #c76508; + --color-run-paused: var(--color-amber-600); + --color-run-timed-out: #ed5f74; +} + /* Streamdown's muted surface has no semantic token (charcoal-775); theme it here */ [data-theme="light"] .streamdown-container { --muted: #eceef1; } -/* The timeline label shadow is a Classic legibility aid; the System themes - drop it entirely */ -:is([data-theme="dark"], [data-theme="light"]) .text-shadow-custom { +/* The timeline label shadow is a legibility aid for the Classic accents; the + high-contrast set doesn't need it */ +[data-icon-contrast="true"] .text-shadow-custom { text-shadow: none; } diff --git a/apps/webapp/app/utils/dashboardPreferences.ts b/apps/webapp/app/utils/dashboardPreferences.ts index ea0c6d8bfe2..0897ad788c4 100644 --- a/apps/webapp/app/utils/dashboardPreferences.ts +++ b/apps/webapp/app/utils/dashboardPreferences.ts @@ -52,6 +52,8 @@ const DashboardPreferences = z.object({ theme: ThemePreference.optional().catch(undefined), /** Interface contrast for the System themes, 0-100. */ contrast: z.number().int().min(0).max(100).optional().catch(undefined), + /** Swaps the Classic icon and badge accents for the high-contrast set. */ + iconContrast: z.boolean().optional().catch(undefined), currentProjectId: z.string().optional(), projects: z.record( z.string(), diff --git a/apps/webapp/app/utils/themePreference.ts b/apps/webapp/app/utils/themePreference.ts index 2b6408c2abc..7b6cc6c67e2 100644 --- a/apps/webapp/app/utils/themePreference.ts +++ b/apps/webapp/app/utils/themePreference.ts @@ -16,6 +16,12 @@ export function normalizeThemePreference(value: unknown): ThemePreference { /** The default dark theme ships with a slight contrast bump. */ export const DEFAULT_THEME_CONTRAST = 50; +/** Icon and badge contrast: on swaps the Classic accents for the high-contrast + * set (solid badges, monochrome nav icons). Off is the default. */ +export function normalizeIconContrast(value: unknown): boolean { + return value === true; +} + /** Interface contrast for the System themes, 0 to 100. Missing or invalid * values fall back to the default bump. */ export function normalizeThemeContrast(value: unknown): number { From 34ad34fa92b6446fc9eaf70fa432ac85b89831c6 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 10 Aug 2026 18:16:11 +0000 Subject: [PATCH 12/25] feat(webapp): add an App sidebar row and descriptions to the profile page Renames the section to "Interface and theme", adds descriptions to each row and grows the theme select a size. The new App sidebar row opens the side menu's own Customize modal - that modal builds its section list from the side menu's project context, which this page doesn't have, so the button deep links into the user's current environment and the param is consumed and stripped there. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/components/navigation/SideMenu.tsx | 13 +++++ .../components/navigation/sideMenuTypes.ts | 4 ++ .../app/routes/account._index/route.tsx | 56 ++++++++++++++++--- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 655572121ab..e9b0a33c5b7 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -6,6 +6,7 @@ import { useLocation, useNavigation, useRevalidator, + useSearchParams, useSubmit, } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; @@ -178,6 +179,7 @@ import { SideMenuItem, SideMenuLabel } from "./SideMenuItem"; import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu"; import { SideMenuSection } from "./SideMenuSection"; import { + CUSTOMIZE_SIDEBAR_PARAM, isItemHidden, orderByPreference, SIDE_MENU_POPOVER_ITEM_ICON, @@ -403,6 +405,17 @@ export function SideMenu({ const isV3Project = project.engine === "V1"; const favorites = useFavorites(); const [isCustomizeOpen, setCustomizeOpen] = useState(false); + // Deep link from the profile page, which has no project context of its own to + // build the section list from. The param is dropped once consumed so a + // refresh (or a later back navigation) doesn't reopen the modal. + const [searchParams, setSearchParams] = useSearchParams(); + useEffect(() => { + if (!searchParams.has(CUSTOMIZE_SIDEBAR_PARAM)) return; + setCustomizeOpen(true); + const remaining = new URLSearchParams(searchParams); + remaining.delete(CUSTOMIZE_SIDEBAR_PARAM); + setSearchParams(remaining, { replace: true, preventScrollReset: true }); + }, [searchParams, setSearchParams]); // Lives here (not in the dialog): the dialog unmounts on close, which would abort a fetcher it // owned mid-request. const customizationFetcher = useFetcher<{ success: boolean }>(); diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index 508c4121175..b031f22ce90 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -14,6 +14,10 @@ export const SideMenuSectionIdSchema = z.enum([ // Inferred type from the schema export type SideMenuSectionId = z.infer; +/** Deep link that opens the "Customize sidebar" modal, so pages outside the app + * shell (the profile page) can reach it. Consumed and stripped by SideMenu. */ +export const CUSTOMIZE_SIDEBAR_PARAM = "customizeSidebar"; + // Size popover items to match the side-menu items, overriding the smaller small-menu-item // defaults via tailwind-merge; icon carries the default dimmed color. export const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 40fb830c97f..0473e1127ab 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -10,7 +10,7 @@ import { PageBody, PageContainer, } from "~/components/layout/AppLayout"; -import { Button } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Select, SelectItem } from "~/components/primitives/Select"; import { Slider } from "~/components/primitives/Slider"; import { FormError } from "~/components/primitives/FormError"; @@ -20,8 +20,11 @@ import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Switch } from "~/components/primitives/Switch"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { CUSTOMIZE_SIDEBAR_PARAM } from "~/components/navigation/sideMenuTypes"; import { ALL_THEME_OPTIONS, THEME_OPTIONS_BY_VALUE } from "~/components/themeOptions"; import { prisma } from "~/db.server"; +import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; import { useUser } from "~/hooks/useUser"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { updateUser } from "~/models/user.server"; @@ -39,7 +42,7 @@ import { import { cachedFlag } from "~/v3/featureFlags.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { emailSchema, MAX_EMAIL_LENGTH } from "~/utils/emailValidation"; -import { accountPath } from "~/utils/pathBuilder"; +import { accountPath, v3EnvironmentPath } from "~/utils/pathBuilder"; import { pageMeta } from "~/utils/pageTitle"; export const meta = pageMeta("Your profile"); @@ -96,7 +99,25 @@ export async function loader({ request }: LoaderFunctionArgs) { const user = await requireUser(request); const showThemeSwitcher = user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })); - return json({ showThemeSwitcher }); + + // The customize modal builds its section list from the side menu's project + // context, which this page doesn't have - so the button deep links into the + // user's current environment instead. Null when they have no project yet. + let customizeSidebarPath: string | null = null; + try { + const { organization, project, environment } = await new SelectBestEnvironmentPresenter().call({ + user, + }); + customizeSidebarPath = `${v3EnvironmentPath( + organization, + project, + environment + )}?${CUSTOMIZE_SIDEBAR_PARAM}=true`; + } catch { + // No project to customize a sidebar for; the row hides itself + } + + return json({ showThemeSwitcher, customizeSidebarPath }); } export const action: ActionFunction = async ({ request }) => { @@ -188,7 +209,7 @@ export const action: ActionFunction = async ({ request }) => { export default function Page() { const user = useUser(); - const { showThemeSwitcher } = useLoaderData(); + const { showThemeSwitcher, customizeSidebarPath } = useLoaderData(); const lastSubmission = useActionData(); const themeFetcher = useFetcher(); const contrastFetcher = useFetcher(); @@ -319,16 +340,34 @@ export default function Page() { {showThemeSwitcher && ( <>
- Appearance + Interface and theme
+ {customizeSidebarPath && ( +
+
+ + + + Customize sidebar item visibility, order and rename favorites + + +
+ + Customize + +
+
+
+ )}
- + + Choose your interface color scheme
- aria-label="Theme" + aria-label="Interface theme" value={theme} setValue={(value) => themeFetcher.submit( @@ -336,7 +375,7 @@ export default function Page() { { method: "post" } ) } - variant="secondary/small" + variant="secondary/medium" dropdownIcon items={ALL_THEME_OPTIONS.map((option) => option.value)} text={(value) => ( @@ -373,6 +412,7 @@ export default function Page() {
+ Adjust the interface contrast
Date: Mon, 10 Aug 2026 19:07:12 +0000 Subject: [PATCH 13/25] fix(webapp): tighten settings row descriptions Drops the row description a size (small to extra-small, 14px to 12px) and halves the title-to-description gap, both in the shared settings layout so every settings page follows. The gap moves to an exported constant, since the profile page hand-rolls its rows to match the Profile section's heights. Also describes what the Icon contrast switch does. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/primitives/SettingsLayout.tsx | 12 ++++--- .../app/routes/account._index/route.tsx | 35 ++++++++++++------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/components/primitives/SettingsLayout.tsx b/apps/webapp/app/components/primitives/SettingsLayout.tsx index dae340e9835..d9a7540c3e0 100644 --- a/apps/webapp/app/components/primitives/SettingsLayout.tsx +++ b/apps/webapp/app/components/primitives/SettingsLayout.tsx @@ -114,7 +114,7 @@ export function SettingsRowTitle({ ); } -/** Description/subtitle typography for a row. */ +/** Description/subtitle typography for a row - a step down from the title. */ export function SettingsRowDescription({ children, className, @@ -123,12 +123,16 @@ export function SettingsRowDescription({ className?: string; }) { return ( - + {children} ); } +/** Title-to-description spacing, kept in one place so every settings row and + * anything hand-rolling the pair reads the same. */ +export const SETTINGS_ROW_TITLE_GAP = "space-y-0.5"; + /** * A single settings row: title + description on the left, action on the right. * @@ -170,7 +174,7 @@ export function SettingsRow({ )} > {children ?? ( -
+
{title ? ( {title} @@ -229,7 +233,7 @@ export function SettingsAlertRow({ return ( -
+
{title} diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 0473e1127ab..db25f2ca73d 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -20,7 +20,10 @@ import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Switch } from "~/components/primitives/Switch"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; -import { Paragraph } from "~/components/primitives/Paragraph"; +import { + SETTINGS_ROW_TITLE_GAP, + SettingsRowDescription, +} from "~/components/primitives/SettingsLayout"; import { CUSTOMIZE_SIDEBAR_PARAM } from "~/components/navigation/sideMenuTypes"; import { ALL_THEME_OPTIONS, THEME_OPTIONS_BY_VALUE } from "~/components/themeOptions"; import { prisma } from "~/db.server"; @@ -44,6 +47,7 @@ import { requireUser, requireUserId } from "~/services/session.server"; import { emailSchema, MAX_EMAIL_LENGTH } from "~/utils/emailValidation"; import { accountPath, v3EnvironmentPath } from "~/utils/pathBuilder"; import { pageMeta } from "~/utils/pageTitle"; +import { cn } from "~/utils/cn"; export const meta = pageMeta("Your profile"); @@ -345,12 +349,12 @@ export default function Page() { {customizeSidebarPath && (
- +
- + Customize sidebar item visibility, order and rename favorites - - + +
Customize @@ -361,10 +365,12 @@ export default function Page() { )}
- +
- Choose your interface color scheme - + + Choose your interface color scheme + +
aria-label="Interface theme" @@ -410,10 +416,10 @@ export default function Page() { {theme !== "classic" && (
- +
- Adjust the interface contrast - + Adjust the interface contrast +
- +
- + + Increase the contrast of icons and badges + +
Date: Mon, 10 Aug 2026 19:30:59 +0000 Subject: [PATCH 14/25] feat(webapp): open the customize sidebar modal from the profile page The App sidebar row now opens the modal in place instead of navigating into the app to open it there. The side menu's section list moves into its own module so both callers build it from one source - the profile page resolves the user's current environment in its loader and passes it in, since the list is keyed to a project and environment. Also grows the Customize button a size and lets the theme select hug its label. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/components/navigation/SideMenu.tsx | 316 ++---------------- .../navigation/sideMenuSections.tsx | 297 ++++++++++++++++ .../components/navigation/sideMenuTypes.ts | 4 - .../app/routes/account._index/route.tsx | 178 ++++++++-- 4 files changed, 479 insertions(+), 316 deletions(-) create mode 100644 apps/webapp/app/components/navigation/sideMenuSections.tsx diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index e9b0a33c5b7..427a3551181 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -6,7 +6,6 @@ import { useLocation, useNavigation, useRevalidator, - useSearchParams, useSubmit, } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; @@ -21,44 +20,27 @@ import { useState, } from "react"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; -import { AIPenIcon } from "~/assets/icons/AIPenIcon"; import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon"; import { ArrowRightSquareIcon } from "~/assets/icons/ArrowRightSquareIcon"; import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; -import { BatchesIcon } from "~/assets/icons/BatchesIcon"; import { BellIcon } from "~/assets/icons/BellIcon"; -import { Box3DIcon } from "~/assets/icons/Box3DIcon"; -import { BugIcon } from "~/assets/icons/BugIcon"; import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; -import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; -import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; -import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; -import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon"; -import { DialIcon } from "~/assets/icons/DialIcon"; import { DropdownIcon } from "~/assets/icons/DropdownIcon"; -import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { EyeClosedIcon } from "~/assets/icons/EyeClosedIcon"; import { EyeOpenIcon } from "~/assets/icons/EyeOpenIcon"; import { FolderClosedIcon } from "~/assets/icons/FolderClosedIcon"; import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon"; -import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; import { HomeIcon } from "~/assets/icons/HomeIcon"; -import { IDIcon } from "~/assets/icons/IDIcon"; import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; -import { KeyIcon } from "~/assets/icons/KeyIcon"; import { LeftSideMenuCollapsedIcon } from "~/assets/icons/LeftSideMenuCollapsedIcon"; import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon"; -import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; -import { LogsIcon } from "~/assets/icons/LogsIcon"; import { PlusIcon } from "~/assets/icons/PlusIcon"; -import { QueuesIcon } from "~/assets/icons/QueuesIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { ShieldIcon } from "~/assets/icons/ShieldIcon"; import { SidebarCustomizeIcon } from "~/assets/icons/SidebarCustomizeIcon"; import { SlidersIcon } from "~/assets/icons/SlidersIcon"; import { TasksIcon } from "~/assets/icons/TasksIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; -import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; import { UserCrossIcon } from "~/assets/icons/UserCrossIcon"; import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; @@ -90,9 +72,6 @@ import { accountSecurityPath, personalAccessTokensPath, adminPath, - branchesPath, - concurrencyPath, - limitsPath, logoutPath, newOrganizationPath, newProjectPath, @@ -103,35 +82,19 @@ import { organizationSsoPath, organizationTeamPath, organizationVercelIntegrationPath, - queryPath, - regionsPath, - v3ApiKeysPath, - v3BatchesPath, v3BillingLimitsPath, v3BillingPath, v3PrivateConnectionsPath, - v3BulkActionsPath, - v3DashboardsLandingPath, - v3DeploymentsPath, v3EnvironmentPath, - v3EnvironmentVariablesPath, - v3ErrorsPath, - v3LogsPath, - v3ModelsPath, - v3ProjectAlertsPath, v3ProjectPath, v3ProjectSettingsGeneralPath, - v3ProjectSettingsIntegrationsPath, - v3PromptsPath, - v3QueuesPath, v3RunsPath, v3SessionsPath, v3UsagePath, - v3WaitpointTokensPath, } from "~/utils/pathBuilder"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; -import { AlphaBadge, NewBadge } from "../FeatureBadges"; +import { NewBadge } from "../FeatureBadges"; import { Button, LinkButton } from "../primitives/Buttons"; import { Dialog, DialogTrigger } from "../primitives/Dialog"; import { type RenderIcon } from "../primitives/Icon"; @@ -176,10 +139,10 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { NotificationPanel } from "./NotificationPanel"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem, SideMenuLabel } from "./SideMenuItem"; +import { buildSideMenuSections, type SideMenuSectionConfig } from "./sideMenuSections"; import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu"; import { SideMenuSection } from "./SideMenuSection"; import { - CUSTOMIZE_SIDEBAR_PARAM, isItemHidden, orderByPreference, SIDE_MENU_POPOVER_ITEM_ICON, @@ -195,31 +158,6 @@ function getSectionCollapsed( return sideMenu?.collapsedSections?.[sectionId] ?? false; } -type SideMenuItemConfig = { - /** Stable id used for hidden/order preferences; never rename once shipped. */ - id: string; - name: string; - icon: RenderIcon; - activeIconColor: string; - inactiveIconColor?: string; - to: string; - dataAction?: string; - badge?: ReactNode; - trailingIconClassName?: string; - /** Hidden for every user who hasn't set their own preference for this item. */ - defaultHidden?: boolean; - /** Right-side action (e.g. the + button on Dashboards); only rendered when visible. */ - action?: ReactNode; - /** Extra content rendered directly after the item (e.g. the dashboards list). */ - after?: ReactNode; -}; - -type SideMenuSectionConfig = { - id: SideMenuSectionId; - title: string; - items: SideMenuItemConfig[]; -}; - // Impersonation accent (menu border + "Stop impersonating"). Full class strings so Tailwind's // static scanner picks them up. const IMPERSONATION_ACCENT = { @@ -405,17 +343,6 @@ export function SideMenu({ const isV3Project = project.engine === "V1"; const favorites = useFavorites(); const [isCustomizeOpen, setCustomizeOpen] = useState(false); - // Deep link from the profile page, which has no project context of its own to - // build the section list from. The param is dropped once consumed so a - // refresh (or a later back navigation) doesn't reopen the modal. - const [searchParams, setSearchParams] = useSearchParams(); - useEffect(() => { - if (!searchParams.has(CUSTOMIZE_SIDEBAR_PARAM)) return; - setCustomizeOpen(true); - const remaining = new URLSearchParams(searchParams); - remaining.delete(CUSTOMIZE_SIDEBAR_PARAM); - setSearchParams(remaining, { replace: true, preventScrollReset: true }); - }, [searchParams, setSearchParams]); // Lives here (not in the dialog): the dialog unmounts on close, which would abort a fetcher it // owned mid-request. const customizationFetcher = useFetcher<{ success: boolean }>(); @@ -804,219 +731,32 @@ export function SideMenu({ // The customizable sections (everything except Tasks/Runs/Sessions), in DEFAULT order. The // user's saved order/hidden preferences are applied at render below. - const staticSections: SideMenuSectionConfig[] = []; - - if (isAdmin || featureFlags.hasAiAccess) { - staticSections.push({ - id: "ai", - title: "AI", - items: [ - { - id: "prompts", - name: "Prompts", - icon: AIPenIcon, - trailingIconClassName: "size-6", - activeIconColor: "text-aiPrompts", - to: v3PromptsPath(organization, project, environment), - dataAction: "prompts", - badge: , - }, - { - id: "models", - name: "Models", - icon: Box3DIcon, - activeIconColor: "text-models", - to: v3ModelsPath(organization, project, environment), - dataAction: "models", - badge: , - }, - ], - }); - } - - if (isAdmin || featureFlags.hasQueryAccess) { - staticSections.push({ - id: "metrics", - title: "Observability", - items: [ - ...(isAdmin || featureFlags.hasLogsPageAccess - ? [ - { - id: "logs", - name: "Logs", - icon: LogsIcon, - activeIconColor: "text-logs", - to: v3LogsPath(organization, project, environment), - dataAction: "logs", - badge: , - } satisfies SideMenuItemConfig, - ] - : []), - { - id: "errors", - name: "Errors", - icon: BugIcon, - activeIconColor: "text-errors", - to: v3ErrorsPath(organization, project, environment), - dataAction: "errors", - }, - { - id: "query", - name: "Query", - icon: CodeSquareIcon, - activeIconColor: "text-query", - to: queryPath(organization, project, environment), - dataAction: "query", - }, - { - id: "queues", - name: "Queues", - icon: QueuesIcon, - activeIconColor: "text-queues", - to: v3QueuesPath(organization, project, environment), - dataAction: "queues", - }, - { - id: "dashboards", - name: "Dashboards", - icon: ChartBarIcon, - activeIconColor: "text-metrics", - to: v3DashboardsLandingPath(organization, project, environment), - dataAction: "dashboards-landing", - action: ( - - ), - after: ( - - ), - }, - ], - }); - } - - staticSections.push({ - id: "deployments", - title: "Deployments", - items: [ - { - id: "deployments", - name: "Deploys", - icon: DeploymentsIcon, - activeIconColor: "text-deployments", - to: v3DeploymentsPath(organization, project, environment), - dataAction: "deployments", - }, - { - id: "environment-variables", - name: "Environment variables", - icon: IDIcon, - activeIconColor: "text-environmentVariables", - to: v3EnvironmentVariablesPath(organization, project, environment), - dataAction: "environment variables", - }, - { - id: "preview-branches", - name: "Preview branches", - icon: BranchEnvironmentIconSmall, - activeIconColor: "text-previewBranches", - to: branchesPath(organization, project, environment), - dataAction: "preview-branches", - }, - { - id: "regions", - name: "Regions", - icon: GlobeLinesIcon, - activeIconColor: "text-regions", - to: regionsPath(organization, project, environment), - dataAction: "regions", - }, - ], - }); - - staticSections.push({ - id: "manage", - title: "Manage", - items: [ - { - id: "waitpoint-tokens", - name: "Waitpoint tokens", - icon: WaitpointTokenIcon, - activeIconColor: "text-sky-500", - to: v3WaitpointTokensPath(organization, project, environment), - dataAction: "waitpoint-tokens", - }, - { - id: "batches", - name: "Batches", - icon: BatchesIcon, - activeIconColor: "text-batches", - to: v3BatchesPath(organization, project, environment), - dataAction: "batches", - }, - { - id: "bulk-actions", - name: "Bulk actions", - icon: ListCheckedIcon, - activeIconColor: "text-text-bright", - to: v3BulkActionsPath(organization, project, environment), - dataAction: "bulk actions", - }, - { - id: "api-keys", - name: "API keys", - icon: KeyIcon, - activeIconColor: "text-text-bright", - to: v3ApiKeysPath(organization, project, environment), - dataAction: "api keys", - }, - { - id: "alerts", - name: "Alerts", - icon: BellIcon, - activeIconColor: "text-text-bright", - to: v3ProjectAlertsPath(organization, project, environment), - dataAction: "alerts", - }, - ...(isManagedCloud - ? [ - { - id: "concurrency", - name: "Concurrency", - icon: ConcurrencyIcon, - activeIconColor: "text-text-bright", - to: concurrencyPath(organization, project, environment), - dataAction: "concurrency", - } satisfies SideMenuItemConfig, - ] - : []), - { - id: "limits", - name: "Limits", - icon: DialIcon, - activeIconColor: "text-text-bright", - to: limitsPath(organization, project, environment), - dataAction: "limits", - }, - { - id: "integrations", - name: "Integrations", - icon: IntegrationsIcon, - activeIconColor: "text-text-bright", - to: v3ProjectSettingsIntegrationsPath(organization, project, environment), - dataAction: "project-settings-integrations", - }, - ], + const staticSections = buildSideMenuSections({ + organization, + project, + environment, + isAdmin, + featureFlags, + isManagedCloud, + dashboards: { + action: ( + + ), + after: ( + + ), + }, }); const sideMenuPrefs = user.dashboardPreferences.sideMenu; diff --git a/apps/webapp/app/components/navigation/sideMenuSections.tsx b/apps/webapp/app/components/navigation/sideMenuSections.tsx new file mode 100644 index 00000000000..e625c3b7a74 --- /dev/null +++ b/apps/webapp/app/components/navigation/sideMenuSections.tsx @@ -0,0 +1,297 @@ +import { type ReactNode } from "react"; +import { AIPenIcon } from "~/assets/icons/AIPenIcon"; +import { BatchesIcon } from "~/assets/icons/BatchesIcon"; +import { BellIcon } from "~/assets/icons/BellIcon"; +import { Box3DIcon } from "~/assets/icons/Box3DIcon"; +import { BugIcon } from "~/assets/icons/BugIcon"; +import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; +import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon"; +import { DialIcon } from "~/assets/icons/DialIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; +import { IDIcon } from "~/assets/icons/IDIcon"; +import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; +import { KeyIcon } from "~/assets/icons/KeyIcon"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; +import { LogsIcon } from "~/assets/icons/LogsIcon"; +import { QueuesIcon } from "~/assets/icons/QueuesIcon"; +import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { + type EnvironmentForPath, + type OrgForPath, + type ProjectForPath, + branchesPath, + concurrencyPath, + limitsPath, + queryPath, + regionsPath, + v3ApiKeysPath, + v3BatchesPath, + v3BulkActionsPath, + v3DashboardsLandingPath, + v3DeploymentsPath, + v3EnvironmentVariablesPath, + v3ErrorsPath, + v3LogsPath, + v3ModelsPath, + v3ProjectAlertsPath, + v3ProjectSettingsIntegrationsPath, + v3PromptsPath, + v3QueuesPath, + v3WaitpointTokensPath, +} from "~/utils/pathBuilder"; +import { AlphaBadge, NewBadge } from "../FeatureBadges"; +import { type RenderIcon } from "../primitives/Icon"; +import { type SideMenuSectionId } from "./sideMenuTypes"; + +// The side menu's customizable sections (everything except Tasks/Runs/Sessions), in DEFAULT order. +// Lives outside SideMenu so the profile page can build the same list for the "Customize sidebar" +// modal - it has no side menu of its own to read them from. + +export type SideMenuItemConfig = { + /** Stable id used for hidden/order preferences; never rename once shipped. */ + id: string; + name: string; + icon: RenderIcon; + activeIconColor: string; + inactiveIconColor?: string; + to: string; + dataAction?: string; + badge?: ReactNode; + trailingIconClassName?: string; + /** Hidden for every user who hasn't set their own preference for this item. */ + defaultHidden?: boolean; + /** Right-side action (e.g. the + button on Dashboards); only rendered when visible. */ + action?: ReactNode; + /** Extra content rendered directly after the item (e.g. the dashboards list). */ + after?: ReactNode; +}; + +export type SideMenuSectionConfig = { + id: SideMenuSectionId; + title: string; + items: SideMenuItemConfig[]; +}; + +export function buildSideMenuSections({ + organization, + project, + environment, + isAdmin, + featureFlags, + isManagedCloud, + dashboards, +}: { + organization: OrgForPath; + project: ProjectForPath; + environment: EnvironmentForPath; + isAdmin: boolean; + featureFlags: { hasAiAccess?: boolean; hasQueryAccess?: boolean; hasLogsPageAccess?: boolean }; + isManagedCloud: boolean; + /** Side-menu-only extras on the Dashboards item; the customize modal has no use for them. */ + dashboards?: { action?: ReactNode; after?: ReactNode }; +}): SideMenuSectionConfig[] { + const staticSections: SideMenuSectionConfig[] = []; + + if (isAdmin || featureFlags.hasAiAccess) { + staticSections.push({ + id: "ai", + title: "AI", + items: [ + { + id: "prompts", + name: "Prompts", + icon: AIPenIcon, + trailingIconClassName: "size-6", + activeIconColor: "text-aiPrompts", + to: v3PromptsPath(organization, project, environment), + dataAction: "prompts", + badge: , + }, + { + id: "models", + name: "Models", + icon: Box3DIcon, + activeIconColor: "text-models", + to: v3ModelsPath(organization, project, environment), + dataAction: "models", + badge: , + }, + ], + }); + } + + if (isAdmin || featureFlags.hasQueryAccess) { + staticSections.push({ + id: "metrics", + title: "Observability", + items: [ + ...(isAdmin || featureFlags.hasLogsPageAccess + ? [ + { + id: "logs", + name: "Logs", + icon: LogsIcon, + activeIconColor: "text-logs", + to: v3LogsPath(organization, project, environment), + dataAction: "logs", + badge: , + } satisfies SideMenuItemConfig, + ] + : []), + { + id: "errors", + name: "Errors", + icon: BugIcon, + activeIconColor: "text-errors", + to: v3ErrorsPath(organization, project, environment), + dataAction: "errors", + }, + { + id: "query", + name: "Query", + icon: CodeSquareIcon, + activeIconColor: "text-query", + to: queryPath(organization, project, environment), + dataAction: "query", + }, + { + id: "queues", + name: "Queues", + icon: QueuesIcon, + activeIconColor: "text-queues", + to: v3QueuesPath(organization, project, environment), + dataAction: "queues", + }, + { + id: "dashboards", + name: "Dashboards", + icon: ChartBarIcon, + activeIconColor: "text-metrics", + to: v3DashboardsLandingPath(organization, project, environment), + dataAction: "dashboards-landing", + action: dashboards?.action, + after: dashboards?.after, + }, + ], + }); + } + + staticSections.push({ + id: "deployments", + title: "Deployments", + items: [ + { + id: "deployments", + name: "Deploys", + icon: DeploymentsIcon, + activeIconColor: "text-deployments", + to: v3DeploymentsPath(organization, project, environment), + dataAction: "deployments", + }, + { + id: "environment-variables", + name: "Environment variables", + icon: IDIcon, + activeIconColor: "text-environmentVariables", + to: v3EnvironmentVariablesPath(organization, project, environment), + dataAction: "environment variables", + }, + { + id: "preview-branches", + name: "Preview branches", + icon: BranchEnvironmentIconSmall, + activeIconColor: "text-previewBranches", + to: branchesPath(organization, project, environment), + dataAction: "preview-branches", + }, + { + id: "regions", + name: "Regions", + icon: GlobeLinesIcon, + activeIconColor: "text-regions", + to: regionsPath(organization, project, environment), + dataAction: "regions", + }, + ], + }); + + staticSections.push({ + id: "manage", + title: "Manage", + items: [ + { + id: "waitpoint-tokens", + name: "Waitpoint tokens", + icon: WaitpointTokenIcon, + activeIconColor: "text-sky-500", + to: v3WaitpointTokensPath(organization, project, environment), + dataAction: "waitpoint-tokens", + }, + { + id: "batches", + name: "Batches", + icon: BatchesIcon, + activeIconColor: "text-batches", + to: v3BatchesPath(organization, project, environment), + dataAction: "batches", + }, + { + id: "bulk-actions", + name: "Bulk actions", + icon: ListCheckedIcon, + activeIconColor: "text-text-bright", + to: v3BulkActionsPath(organization, project, environment), + dataAction: "bulk actions", + }, + { + id: "api-keys", + name: "API keys", + icon: KeyIcon, + activeIconColor: "text-text-bright", + to: v3ApiKeysPath(organization, project, environment), + dataAction: "api keys", + }, + { + id: "alerts", + name: "Alerts", + icon: BellIcon, + activeIconColor: "text-text-bright", + to: v3ProjectAlertsPath(organization, project, environment), + dataAction: "alerts", + }, + ...(isManagedCloud + ? [ + { + id: "concurrency", + name: "Concurrency", + icon: ConcurrencyIcon, + activeIconColor: "text-text-bright", + to: concurrencyPath(organization, project, environment), + dataAction: "concurrency", + } satisfies SideMenuItemConfig, + ] + : []), + { + id: "limits", + name: "Limits", + icon: DialIcon, + activeIconColor: "text-text-bright", + to: limitsPath(organization, project, environment), + dataAction: "limits", + }, + { + id: "integrations", + name: "Integrations", + icon: IntegrationsIcon, + activeIconColor: "text-text-bright", + to: v3ProjectSettingsIntegrationsPath(organization, project, environment), + dataAction: "project-settings-integrations", + }, + ], + }); + + return staticSections; +} diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index b031f22ce90..508c4121175 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -14,10 +14,6 @@ export const SideMenuSectionIdSchema = z.enum([ // Inferred type from the schema export type SideMenuSectionId = z.infer; -/** Deep link that opens the "Customize sidebar" modal, so pages outside the app - * shell (the profile page) can reach it. Consumed and stripped by SideMenu. */ -export const CUSTOMIZE_SIDEBAR_PARAM = "customizeSidebar"; - // Size popover items to match the side-menu items, overriding the smaller small-menu-item // defaults via tailwind-merge; icon carries the default dimmed color. export const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index db25f2ca73d..4664cccf0e8 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -1,5 +1,5 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { conformZodMessage, parseWithZod } from "@conform-to/zod"; import { Form, useActionData, useFetcher, useLoaderData } from "@remix-run/react"; import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; @@ -10,7 +10,8 @@ import { PageBody, PageContainer, } from "~/components/layout/AppLayout"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Button } from "~/components/primitives/Buttons"; +import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; import { Select, SelectItem } from "~/components/primitives/Select"; import { Slider } from "~/components/primitives/Slider"; import { FormError } from "~/components/primitives/FormError"; @@ -24,11 +25,22 @@ import { SETTINGS_ROW_TITLE_GAP, SettingsRowDescription, } from "~/components/primitives/SettingsLayout"; -import { CUSTOMIZE_SIDEBAR_PARAM } from "~/components/navigation/sideMenuTypes"; +import { + CustomizeSidebarDialog, + type CustomizeSidebarSection, +} from "~/components/navigation/CustomizeSidebarDialog"; +import { + favoritePageIcon, + favoritePageIconClassName, + useFavorites, +} from "~/components/navigation/favoritePages"; +import { buildSideMenuSections } from "~/components/navigation/sideMenuSections"; import { ALL_THEME_OPTIONS, THEME_OPTIONS_BY_VALUE } from "~/components/themeOptions"; import { prisma } from "~/db.server"; import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; -import { useUser } from "~/hooks/useUser"; +import { useFeatureFlags } from "~/hooks/useFeatureFlags"; +import { useFeatures } from "~/hooks/useFeatures"; +import { useHasAdminAccess, useUser } from "~/hooks/useUser"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { updateUser } from "~/models/user.server"; import { @@ -45,7 +57,7 @@ import { import { cachedFlag } from "~/v3/featureFlags.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { emailSchema, MAX_EMAIL_LENGTH } from "~/utils/emailValidation"; -import { accountPath, v3EnvironmentPath } from "~/utils/pathBuilder"; +import { accountPath } from "~/utils/pathBuilder"; import { pageMeta } from "~/utils/pageTitle"; import { cn } from "~/utils/cn"; @@ -104,24 +116,29 @@ export async function loader({ request }: LoaderFunctionArgs) { const showThemeSwitcher = user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })); - // The customize modal builds its section list from the side menu's project - // context, which this page doesn't have - so the button deep links into the - // user's current environment instead. Null when they have no project yet. - let customizeSidebarPath: string | null = null; + // The customize modal's section list is built from the side menu's items, which + // are keyed to a project and environment. Resolve the user's current one so the + // modal can open here rather than sending them into the app to find it. Null + // when they have no project yet, and the row hides itself. + let sidebarContext: { + organization: { slug: string }; + project: { slug: string }; + environment: { slug: string }; + } | null = null; try { const { organization, project, environment } = await new SelectBestEnvironmentPresenter().call({ user, }); - customizeSidebarPath = `${v3EnvironmentPath( - organization, - project, - environment - )}?${CUSTOMIZE_SIDEBAR_PARAM}=true`; + sidebarContext = { + organization: { slug: organization.slug }, + project: { slug: project.slug }, + environment: { slug: environment.slug }, + }; } catch { - // No project to customize a sidebar for; the row hides itself + // No project to customize a sidebar for } - return json({ showThemeSwitcher, customizeSidebarPath }); + return json({ showThemeSwitcher, sidebarContext }); } export const action: ActionFunction = async ({ request }) => { @@ -211,9 +228,124 @@ export const action: ActionFunction = async ({ request }) => { } }; +/** + * Opens the side menu's own "Customize sidebar" modal from here, so the settings row doesn't send + * anyone into the app to find it. The section list is built from the same source the side menu + * renders from, keyed to the user's current project and environment. + * + * The fetcher lives here rather than in the dialog because closing the dialog unmounts it, which + * would abort a save mid-request - the same reason the side menu owns its copy. + */ +function CustomizeSidebarButton({ + context, +}: { + context: { + organization: { slug: string }; + project: { slug: string }; + environment: { slug: string }; + }; +}) { + const user = useUser(); + const isAdmin = useHasAdminAccess(); + const featureFlags = useFeatureFlags(); + const { isManagedCloud } = useFeatures(); + const favorites = useFavorites(); + const [isOpen, setIsOpen] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [error, setError] = useState(); + const fetcher = useFetcher<{ success: boolean }>(); + // The fetcher's data outlives a confirm, so only settle once THIS submission has been in flight. + const submitSeenRef = useRef(false); + + useEffect(() => { + if (!isConfirming) return; + if (fetcher.state !== "idle") { + submitSeenRef.current = true; + return; + } + if (!submitSeenRef.current) return; + setIsConfirming(false); + if (fetcher.data?.success) { + setIsOpen(false); + } else { + setError("Couldn't save your changes. Please try again."); + } + }, [isConfirming, fetcher.state, fetcher.data]); + + const sideMenuPrefs = user.dashboardPreferences.sideMenu; + const sections: CustomizeSidebarSection[] = [ + ...(favorites.length > 0 + ? [ + { + id: "favorites", + title: "Favorites", + items: favorites.map((favorite) => ({ + id: favorite.id, + name: favorite.label, + icon: favoritePageIcon(favorite.icon), + iconClassName: favoritePageIconClassName(favorite.icon), + isFavorite: true, + })), + }, + ] + : []), + ...buildSideMenuSections({ ...context, isAdmin, featureFlags, isManagedCloud }).map( + (section) => ({ + id: section.id, + title: section.title, + items: section.items.map((item) => ({ + id: item.id, + name: item.name, + icon: item.icon, + defaultHidden: item.defaultHidden, + })), + }) + ), + ]; + + return ( + { + setIsOpen(open); + if (!open) { + setIsConfirming(false); + setError(undefined); + } + }} + > + + + + {/* Mounted only while open so the modal re-seeds from current preferences each time */} + {isOpen && ( + { + setError(undefined); + setIsConfirming(true); + submitSeenRef.current = false; + fetcher.submit( + { customization: JSON.stringify(payload) }, + { method: "POST", action: "/resources/preferences/sidemenu" } + ); + }} + isConfirming={isConfirming} + confirmError={error} + /> + )} + + ); +} + export default function Page() { const user = useUser(); - const { showThemeSwitcher, customizeSidebarPath } = useLoaderData(); + const { showThemeSwitcher, sidebarContext } = useLoaderData(); const lastSubmission = useActionData(); const themeFetcher = useFetcher(); const contrastFetcher = useFetcher(); @@ -346,7 +478,7 @@ export default function Page() {
Interface and theme
- {customizeSidebarPath && ( + {sidebarContext && (
@@ -356,9 +488,7 @@ export default function Page() {
- - Customize - +
@@ -390,9 +520,9 @@ export default function Page() { {THEME_OPTIONS_BY_VALUE[value].label} )} - // Sized to the widest option (Classic, 106px) so no label - // squeezes its icon, rounded up to the nearest step. - className="w-27" + // Hugs its label; the popover keeps the wider floor below so + // the options aren't cramped by the shortest one. + className="w-fit" // The popover's 180px floor left a gap past the longest // label; match the trigger instead. popoverClassName="min-w-27" From f1b7413e6abbd755cd1e57cfedfd51d1d55d5a95 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 08:43:42 +0000 Subject: [PATCH 15/25] fix(webapp): apply the theme on click, not on the loader's next pass Picking a theme from the account menu sometimes left the old one on screen. The switch waited for the write to come back through the root loader, but dismissing the popover unmounts the row that owns the fetcher, and without v3_fetcherPersist React Router drops an unmounted fetcher's revalidation. The POST had already gone out, so the preference saved and a refresh showed the new theme - which is why it looked intermittent. Both pickers now set the attribute themselves and let the write follow, sharing the resolution rule with useSystemThemeSync rather than restating it, and revert if the write comes back unsuccessful. Co-Authored-By: Claude Opus 5 (1M context) --- .../navigation/AppearanceMenuItem.tsx | 30 ++++++++++++++----- apps/webapp/app/hooks/useSystemThemeSync.ts | 19 +++++++++++- .../app/routes/account._index/route.tsx | 10 +++++-- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx index 214b9c04e3b..7890729359e 100644 --- a/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx +++ b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx @@ -1,12 +1,14 @@ 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 } from "~/utils/themePreference"; +import { normalizeThemePreference, type ThemePreference } from "~/utils/themePreference"; import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu"; import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes"; @@ -20,20 +22,36 @@ const THEME_ACTION_PATH = "/resources/preferences/theme"; */ export function AppearanceMenuItem() { const rootData = useTypedRouteLoaderData("root"); - const fetcher = useFetcher(); + 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 theme itself follows once - // the write lands and the root loader revalidates. + // 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. @@ -46,9 +64,7 @@ export function AppearanceMenuItem() { leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} className={SIDE_MENU_POPOVER_ITEM_LABEL} isSelected={theme === option.value} - onClick={() => - fetcher.submit({ theme: option.value }, { method: "post", action: THEME_ACTION_PATH }) - } + onClick={() => pickTheme(option.value)} /> ))}
diff --git a/apps/webapp/app/hooks/useSystemThemeSync.ts b/apps/webapp/app/hooks/useSystemThemeSync.ts index 6b2a678396f..36638094441 100644 --- a/apps/webapp/app/hooks/useSystemThemeSync.ts +++ b/apps/webapp/app/hooks/useSystemThemeSync.ts @@ -1,6 +1,23 @@ import { useEffect } from "react"; import { type ThemePreference } from "~/utils/themePreference"; +/** + * Puts a preference on now, resolving `system` against the OS once. Use + * this to apply a theme the moment it's picked: the preference round-trips + * through the server and comes back via the root loader, and anything that waits + * for that is at the mercy of whether the revalidation actually lands. + */ +export function applyThemePreference(preference: ThemePreference) { + const resolved = + preference === "system" + ? window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : preference; + document.documentElement.setAttribute("data-theme", resolved); + document.documentElement.setAttribute("data-theme-preference", preference); +} + /** * Keeps `data-theme` on in sync with the preference. For `system` it * follows the OS color scheme live; for pinned themes it writes the attribute @@ -13,7 +30,7 @@ import { type ThemePreference } from "~/utils/themePreference"; export function useSystemThemeSync(preference: ThemePreference) { useEffect(() => { if (preference !== "system") { - document.documentElement.setAttribute("data-theme", preference); + applyThemePreference(preference); return; } diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 4664cccf0e8..1f06bcdfb15 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -39,6 +39,7 @@ import { ALL_THEME_OPTIONS, THEME_OPTIONS_BY_VALUE } from "~/components/themeOpt import { prisma } from "~/db.server"; import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; +import { applyThemePreference } from "~/hooks/useSystemThemeSync"; import { useFeatures } from "~/hooks/useFeatures"; import { useHasAdminAccess, useUser } from "~/hooks/useUser"; import { redirectWithSuccessMessage } from "~/models/message.server"; @@ -505,12 +506,15 @@ export default function Page() { aria-label="Interface theme" value={theme} - setValue={(value) => + setValue={(value) => { + // Applied here so the theme lands immediately rather than + // on the root loader's next pass (see applyThemePreference). + applyThemePreference(normalizeThemePreference(value)); themeFetcher.submit( { action: "update-theme", theme: value }, { method: "post" } - ) - } + ); + }} variant="secondary/medium" dropdownIcon items={ALL_THEME_OPTIONS.map((option) => option.value)} From 23533ddad78ddc2188e3480eda44facba0c13b26 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 09:17:06 +0000 Subject: [PATCH 16/25] fix(webapp): brighten the secondary button on hover in the dark themes bg-secondary is charcoal-650 and the hover was background-raised, which is charcoal-700 - a step down the scale, so the button dimmed. Hover now steps up to charcoal-600 (surface-control) on the dark themes, which is where it sat before the themes update. Light keeps darkening off white. Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/components/primitives/Buttons.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 78b201b90e0..c40225b144a 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -69,7 +69,10 @@ const theme = { secondary: { textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80", button: - "bg-secondary border border-border-bright/50 shadow-xs group-hover/button:bg-background-raised group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", + // On light, hover darkens off white. On the dark themes bg-secondary is + // charcoal-650, so hover steps one stop up the scale to charcoal-600 + // (surface-control) - background-raised is charcoal-700, i.e. darker. + "bg-secondary border border-border-bright/50 shadow-xs group-hover/button:bg-background-raised dark:group-hover/button:bg-surface-control group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", shortcut: "border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed", icon: "text-text-bright", From 77ed11080cfb9e0c91ad94a2274016b7b275955a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 09:26:19 +0000 Subject: [PATCH 17/25] fix(webapp): brighten the secondary select on hover in the dark themes Same step the secondary button needed: bg-secondary is charcoal-650 and the hover was background-raised at charcoal-700, so it dimmed. Dark themes now hover to charcoal-600; light keeps darkening off white. Also drops the dropdown chevron's colour transition, the only animated hover on the trigger - the background change is instant, so the chevron was arriving late. Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/components/primitives/Select.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 31921ef9854..de294a63c19 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -29,8 +29,11 @@ const style = { "bg-transparent focus-custom hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none", }, secondary: { + // Hover matches the secondary button: darkens off white on light, and steps + // one stop up the charcoal scale on the dark themes, where background-raised + // (charcoal-700) sits below bg-secondary (charcoal-650) and read as dimming. button: - "bg-secondary focus-custom border border-border-bright/50 shadow-xs hover:text-text-bright text-text-bright hover:bg-background-raised", + "bg-secondary focus-custom border border-border-bright/50 shadow-xs text-text-bright hover:bg-background-raised dark:hover:bg-surface-control", }, }; @@ -356,8 +359,10 @@ export function SelectTrigger({
{dropdownIcon === true ? ( ) : !dropdownIcon ? null : ( From b80cc8c6f6129049e50ef583bdba826ac0c32249 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 09:32:41 +0000 Subject: [PATCH 18/25] feat(webapp): add a surround-free switch variant and move it to indigo Adds minimal/medium: the same toggle as medium without the padded hover box, for rows that don't need the enlarged hit area. medium keeps it. The unchecked track still lightens on hover, so the control keeps an affordance without the surround. The checked fill moves from blue-500 to indigo-500, matching the primary accent the buttons and checkboxes use. Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/components/primitives/Switch.tsx | 10 +++++++++- apps/webapp/app/routes/account._index/route.tsx | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index e07187176a8..34365cade2d 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -52,6 +52,14 @@ const variations = { thumb: "size-3.5 data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0", text: "text-sm text-text-dimmed", }, + /* Like medium, minus the hover box: the toggle is the whole target, for rows + that already carry their own affordance. */ + "minimal/medium": { + container: "flex items-center gap-x-2 rounded-md focus-custom", + root: "h-4 w-8", + thumb: "size-3.5 data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0", + text: "text-sm text-text-dimmed", + }, }; type SwitchProps = React.ComponentPropsWithoutRef & { @@ -91,7 +99,7 @@ export const Switch = React.forwardRef diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 1f06bcdfb15..99e235eec8c 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -593,7 +593,7 @@ export default function Page() {
From e0b7a8a22274ef109692cbb0665e426dc923afba Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 09:37:22 +0000 Subject: [PATCH 19/25] feat(webapp): drop the switch surround on the onboarding emails row Moves it to minimal/medium so it matches Icon contrast. The old className carried a pr-3 that only made sense with the padded variant; without it the toggle lines up with the other controls in the column. Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/routes/account._index/route.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 99e235eec8c..dc8ed1f20e6 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -461,9 +461,8 @@ export default function Page() {
From 3037a2180b7cf5aeb40d3e01322384e4d5cd0b20 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 09:51:54 +0000 Subject: [PATCH 20/25] fix(webapp): dim the contrast handle and brighten it on hover The handle sat at charcoal-200 on the dark themes, the brightest tone on the row. It rests a stop lower now and comes up to charcoal-200 on hover. Light reverses the direction - it rests white and dims to charcoal-200, since on a white page dimming is what reads as more prominent. Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/components/primitives/Slider.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index 70a52f5d884..a215a2939ea 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -13,10 +13,11 @@ const variants = { root: "h-4 grow", track: "h-1 bg-grid-bright", range: "bg-transparent", - // The secondary-button hairline+shadow keeps the white dot visible on the - // light track + // The secondary-button hairline+shadow keeps the dot visible on the light + // track. Hover moves the handle off its resting tone in whichever direction + // reads as more prominent: brighter on the dark themes, dimmer on light. thumb: - "h-4.5 w-4.5 border border-border-bright bg-white shadow-sm dark:border-transparent dark:bg-charcoal-200 dark:shadow-none", + "h-4.5 w-4.5 border border-border-bright bg-white shadow-sm hover:bg-charcoal-200 dark:border-transparent dark:bg-charcoal-300 dark:shadow-none dark:hover:bg-charcoal-200", thumbSize: 18, // Track-coloured line, notched off the track by borders in the colour of // the page behind it (settings rows sit on background-dimmed). From d9866d88fb7d154d1a037dcab811ea819f56f255 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 09:52:02 +0000 Subject: [PATCH 21/25] fix(webapp): step the Customize button and theme select down a size Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/routes/account._index/route.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index dc8ed1f20e6..676fc1cb0a3 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -316,7 +316,7 @@ function CustomizeSidebarButton({ }} > - + {/* Mounted only while open so the modal re-seeds from current preferences each time */} {isOpen && ( @@ -514,7 +514,7 @@ export default function Page() { { method: "post" } ); }} - variant="secondary/medium" + variant="secondary/small" dropdownIcon items={ALL_THEME_OPTIONS.map((option) => option.value)} text={(value) => ( From 641dc25cc45e5ac8a6e68e40eff59008de0e89e1 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 09:53:43 +0000 Subject: [PATCH 22/25] feat(webapp): floor the contrast slider at 15% and mark the default at 30% The slider no longer offers the bottom of the range, and the "Default" tick moves with it. Nothing about the stored value or the colours it drives changes - the number means what it always did, the control just starts at 15. Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/routes/account._index/route.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 676fc1cb0a3..45c307e6b40 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -64,9 +64,13 @@ import { cn } from "~/utils/cn"; export const meta = pageMeta("Your profile"); +/** Floor of the contrast slider. The stored value and the colours it drives are + * unchanged - this only stops the slider offering the bottom of the range. */ +const MIN_CONTRAST = 15; + /** The contrast the slider ticks and labels as "Default". Note this is not the * same as `DEFAULT_THEME_CONTRAST`, the value applied when none is saved. */ -const DEFAULT_CONTRAST_MARK = 20; +const DEFAULT_CONTRAST_MARK = 30; function themeIcon(value: ThemePreference) { const Icon = THEME_OPTIONS_BY_VALUE[value].icon; @@ -558,7 +562,7 @@ export default function Page() { variant="settings" className="w-44" aria-label="Contrast" - min={0} + min={MIN_CONTRAST} max={100} step={1} marks={[ From bb48233664dde746a698c84aca68c7da2d0188ee Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 14:14:42 +0000 Subject: [PATCH 23/25] feat(webapp): add an Underline links preference Off by default. It targets a marker class on the TextLink component rather than anchors generally, so nav items, buttons-as-links and decorative underlines (dashed tooltip terms, tab underlines) are untouched either way. TextLink itself never underlined - its two variants are colour-only - so this is the first underline it gets. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/components/primitives/TextLink.tsx | 11 +++-- apps/webapp/app/root.tsx | 8 ++++ .../app/routes/account._index/route.tsx | 46 +++++++++++++++++++ .../services/dashboardPreferences.server.ts | 30 ++++++++++++ apps/webapp/app/tailwind.css | 9 ++++ apps/webapp/app/utils/dashboardPreferences.ts | 2 + apps/webapp/app/utils/themePreference.ts | 5 ++ 7 files changed, 107 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/primitives/TextLink.tsx b/apps/webapp/app/components/primitives/TextLink.tsx index d0186268c0c..65ba201c6fd 100644 --- a/apps/webapp/app/components/primitives/TextLink.tsx +++ b/apps/webapp/app/components/primitives/TextLink.tsx @@ -6,11 +6,14 @@ import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKey import { ShortcutKey } from "./ShortcutKey"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip"; +// inline-text-link: marker for the "Underline links" preference, which underlines +// these and nothing else (nav items, buttons-as-links and decorative underlines +// all stay put). See tailwind.css. +const base = "inline-text-link inline-flex gap-0.5 items-center group focus-visible:focus-custom"; + const variations = { - primary: - "text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom", - secondary: - "text-text-dimmed transition hover:text-text-bright inline-flex gap-0.5 items-center group focus-visible:focus-custom", + primary: `${base} text-indigo-500 transition hover:text-indigo-400`, + secondary: `${base} text-text-dimmed transition hover:text-text-bright`, } as const; type TextLinkProps = { diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 8691a7e4a53..45bb11300a9 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -26,6 +26,7 @@ import { getUser } from "./services/session.server"; import { normalizeIconContrast, normalizeThemeContrast, + normalizeUnderlineLinks, normalizeThemePreference, type ThemePreference, } from "~/utils/themePreference"; @@ -100,6 +101,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const iconContrast = showThemeSwitcher ? normalizeIconContrast(user?.dashboardPreferences.iconContrast) : false; + const underlineLinks = showThemeSwitcher + ? normalizeUnderlineLinks(user?.dashboardPreferences.underlineLinks) + : false; // Display-only: while impersonating, an admin can ask to see the dashboard // the way the impersonated user sees it. Exposed from root so every route can // read it. @@ -130,6 +134,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { timezone, showThemeSwitcher, iconContrast, + underlineLinks, themePreference, themeContrast, // Consumed by ResizablePanel: the browser check must match between SSR @@ -184,6 +189,7 @@ export default function App() { themePreference, themeContrast, iconContrast, + underlineLinks, } = useTypedLoaderData(); usePostHog(posthogProjectKey, posthogUiHost); useSystemThemeSync(themePreference); @@ -202,6 +208,8 @@ export default function App() { data-theme-preference={themePreference} // Accent set for icons and badges; the `system:` variant keys off this data-icon-contrast={iconContrast ? "true" : "false"} + // Underlines links carrying the inline-text-link marker class + data-underline-links={underlineLinks ? "true" : "false"} // Contrast overlay input for the System themes; Classic never reads it style={{ "--theme-contrast": themeContrast / 100 } as CSSProperties} > diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 45c307e6b40..4db32544675 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -48,10 +48,12 @@ import { updateContrastPreference, updateIconContrastPreference, updateThemePreference, + updateUnderlineLinksPreference, } from "~/services/dashboardPreferences.server"; import { normalizeIconContrast, normalizeThemeContrast, + normalizeUnderlineLinks, normalizeThemePreference, type ThemePreference, } from "~/utils/themePreference"; @@ -189,6 +191,20 @@ export const action: ActionFunction = async ({ request }) => { return json({ success: true }); } + if (formData.get("action") === "update-underline-links") { + const user = await requireUser(request); + const showThemeSwitcher = + user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })); + if (!showThemeSwitcher) { + return json({ error: "Not available" }, { status: 404 }); + } + await updateUnderlineLinksPreference({ + user, + underlineLinks: formData.get("underlineLinks") === "true", + }); + return json({ success: true }); + } + const formSchema = createSchema({ isEmailUnique: async (email) => { const existingUser = await prisma.user.findFirst({ @@ -360,6 +376,12 @@ export default function Page() { typeof pendingIconContrast === "string" ? pendingIconContrast === "true" : normalizeIconContrast(user.dashboardPreferences.iconContrast); + const underlineLinksFetcher = useFetcher(); + const pendingUnderlineLinks = underlineLinksFetcher.formData?.get("underlineLinks"); + const underlineLinks = + typeof pendingUnderlineLinks === "string" + ? pendingUnderlineLinks === "true" + : normalizeUnderlineLinks(user.dashboardPreferences.underlineLinks); const pendingTheme = themeFetcher.formData?.get("theme"); const pendingContrast = contrastFetcher.formData?.get("contrast"); const contrast = @@ -613,6 +635,30 @@ export default function Page() {
+
+
+
+ + Underline links in body text +
+
+ + underlineLinksFetcher.submit( + { + action: "update-underline-links", + underlineLinks: checked ? "true" : "false", + }, + { method: "post" } + ) + } + /> +
+
+
)} diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 986a39fd4c7..87a73cc4015 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -208,6 +208,36 @@ export async function updateIconContrastPreference({ `; } +export async function updateUnderlineLinksPreference({ + user, + underlineLinks, +}: { + user: UserFromSession; + underlineLinks: boolean; +}) { + if (user.isImpersonating) { + return; + } + + if ((user.dashboardPreferences.underlineLinks ?? false) === underlineLinks) { + return; + } + + // Narrow jsonb_set write: see updateThemePreference. + return prisma.$executeRaw` + UPDATE "User" + SET "dashboardPreferences" = jsonb_set( + COALESCE( + "dashboardPreferences", + '{"version":"1","projects":{}}'::jsonb + ), + '{underlineLinks}', + to_jsonb(${underlineLinks}::boolean) + ) + WHERE id = ${user.id} + `; +} + export async function clearCurrentProject({ user }: { user: UserFromSession }) { if (user.isImpersonating) { return; diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index ba68bc2afcd..dad3c873e32 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -282,6 +282,15 @@ --color-run-timed-out: #ed5f74; } +/* "Underline links" preference: underlines the inline links that carry the + marker class from the TextLink component, and only those - hand-rolled link + underlines and decorative ones (dashed tooltip terms, tab underlines) are + untouched either way. */ +[data-underline-links="true"] .inline-text-link { + text-decoration-line: underline; + text-underline-offset: 2px; +} + /* Icon contrast drops decorative icon accents to monochrome; with it off, the icons stay colored. side-menu-active-icon is set in SideMenuItem for the active nav item; system-mono-icon marks section-header icons (e.g. the diff --git a/apps/webapp/app/utils/dashboardPreferences.ts b/apps/webapp/app/utils/dashboardPreferences.ts index 0897ad788c4..45606749ff4 100644 --- a/apps/webapp/app/utils/dashboardPreferences.ts +++ b/apps/webapp/app/utils/dashboardPreferences.ts @@ -54,6 +54,8 @@ const DashboardPreferences = z.object({ contrast: z.number().int().min(0).max(100).optional().catch(undefined), /** Swaps the Classic icon and badge accents for the high-contrast set. */ iconContrast: z.boolean().optional().catch(undefined), + /** Underlines inline links. */ + underlineLinks: z.boolean().optional().catch(undefined), currentProjectId: z.string().optional(), projects: z.record( z.string(), diff --git a/apps/webapp/app/utils/themePreference.ts b/apps/webapp/app/utils/themePreference.ts index 7b6cc6c67e2..f212336a3c6 100644 --- a/apps/webapp/app/utils/themePreference.ts +++ b/apps/webapp/app/utils/themePreference.ts @@ -22,6 +22,11 @@ export function normalizeIconContrast(value: unknown): boolean { return value === true; } +/** Underlines inline links (the TextLink component). Off is the default. */ +export function normalizeUnderlineLinks(value: unknown): boolean { + return value === true; +} + /** Interface contrast for the System themes, 0 to 100. Missing or invalid * values fall back to the default bump. */ export function normalizeThemeContrast(value: unknown): number { From f2adfeb865d8e275a57dec1d5faa271e4e530e35 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 14:27:15 +0000 Subject: [PATCH 24/25] chore(webapp): temporary underline audit story Every link that underlines itself instead of going through TextLink, copied into its surroundings and labelled, so each can be decided on. Also reworks the Underline links description. Remove the route and its storybook entry once the decisions are made. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/routes/account._index/route.tsx | 4 +- .../storybook.underline-audit/route.tsx | 530 ++++++++++++++++++ apps/webapp/app/routes/storybook/route.tsx | 5 + 3 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 apps/webapp/app/routes/storybook.underline-audit/route.tsx diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 4db32544675..fe4a201c51a 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -639,7 +639,9 @@ export default function Page() {
- Underline links in body text + + Always underline links in body text +
+
{id}
+
+
+ + {file}:{line} + + {kind} +
+ {note ? {note} : null} +
{children}
+
+
+ ); +} + +function Section({ + title, + blurb, + children, +}: { + title: string; + blurb: string; + children: React.ReactNode; +}) { + return ( +
+
+ {title} +
+ + {blurb} + +
{children}
+
+ ); +} + +export default function Story() { + return ( +
+ Underline audit + + Temporary page. Links below underline themselves rather than using{" "} + TextLink, so the "Underline links" + preference leaves them as they are. Toggle the preference in Your profile to compare - only + the TextLink above should change. + + +
+ + Label + + + /orgs/acme/projects/my-project + + + + + + Label + + + https://trigger.dev + + + + + + +

+ We've shipped preview branches.{" "} + + Read the docs + {" "} + to get started. +

+
+ + +

+ The failure first appears in{" "} + + run_c8a91k2p0x + {" "} + and repeats on every retry. +

+
+ + + + + + + + + + + + + + + + When this limit is reached, queued runs will be held for 24 hours, then new triggers + will be rejected until you increase or remove the limit. See our{" "} + + terms + {" "} + for refund policy details. + + + + +
+ + We're live on{" "} + + Product Hunt + + + Vote for us today only! + +
+
+ + + + + + +
+ Match:{" "} + + claude-opus-4-20250514 + +
+
+ + +
+ + claude-sonnet-4-20250514 + +
+
+ + +
gpt-4o-mini-2024-07-18
+
+ + +
+ Deploying from + + triggerdotdev/trigger.dev + + +
+
+ + +
+ Build Server + + + bld_7ac91f2 + + +
+
+
+ +
+ +
+ +
+
+ + +
+ Active team members + + View all role permissions → + +
+
+ + + + +
+ +
+ + + Runs can be{" "} + + debounced + {" "} + before they queue. + + + + + + Includes{" "} + + 100,000 runs + {" "} + per month. + + + + +
+
+ NEXT_PUBLIC_API_URL +
+ + Values with a{" "} + + underline + {" "} + will be overwritten. + +
+
+ + +

+ https://cloud.trigger.dev/orgs/acme/projects/my-project +

+
+ + + + +
+ +
+ +
+ + Primary: an internal link and{" "} + an external one. + + + Secondary (no call sites today):{" "} + + a secondary link + + . + +
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index e033f5d8ee2..a1bf6576229 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -155,6 +155,11 @@ const stories: Story[] = [ name: "Typography", slug: "typography", }, + { + // TEMPORARY - remove with storybook.underline-audit + name: "Underline audit", + slug: "underline-audit", + }, { name: "Unordered list", slug: "unordered-list", From c9e65902408544273efb42e4ea96263f04be63eb Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 11 Aug 2026 18:39:01 +0000 Subject: [PATCH 25/25] refactor(webapp): route hand-rolled links through TextLink Links that underlined themselves now either use TextLink or share its colour and the marker the "Underline links" preference targets, so the preference reaches them. TextLink gains `textLinkClassName` for the cases that can't be the component - markdown links that must stay in the inline flow, and triggers that aren't anchors - plus `reloadDocument` so LabelValueStack keeps its behaviour. Deletes the ProductHunt banner and its image, which nothing rendered. Left alone: the admin pages, and the underlines that aren't links (dashed tooltip terms, the dotted Vercel warning, URL-as-text, the focus affordance). Co-Authored-By: Claude Opus 5 (1M context) --- apps/webapp/app/assets/images/producthunt.png | Bin 5677 -> 0 bytes .../app/components/ProductHuntBanner.tsx | 24 ------------ .../billing/BillingLimitConfigSection.tsx | 6 +-- .../dashboard-agent/RunDiagnosisCard.tsx | 5 ++- .../navigation/NotificationCard.tsx | 3 +- .../components/primitives/LabelValueStack.tsx | 16 +++++--- .../app/components/primitives/TextLink.tsx | 35 ++++++++++++++---- .../runs/v3/agent/AgentMessageView.tsx | 15 ++------ .../runs/v3/ai/AIToolsInventory.tsx | 4 +- .../route.tsx | 9 +++-- .../route.tsx | 13 +++++-- .../TRQLGuideContent.tsx | 10 ++--- .../route.tsx | 10 ++--- ...ces.orgs.$organizationSlug.select-plan.tsx | 14 ++----- 14 files changed, 78 insertions(+), 86 deletions(-) delete mode 100644 apps/webapp/app/assets/images/producthunt.png delete mode 100644 apps/webapp/app/components/ProductHuntBanner.tsx diff --git a/apps/webapp/app/assets/images/producthunt.png b/apps/webapp/app/assets/images/producthunt.png deleted file mode 100644 index e27a96f697651eeb202f8a0372f718c198b7dbb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5677 zcmb_g2UL^Uwho~<6h%Nls#FbRp@K)u0H*M0BJ+%@a2mzDfE|M|aNzPRf2te{8;#4BeidO@x1ZIG+a&P8-#t#NX>M^pbEj( zLB)*D1XOT32pAcGK&t5DbdVT;Ov0e3C^A$9g+QU<2qYYdh9OaSlrA2FQThH-<5^@< zsCXxH%kTDhcSdU7Y&HWAhX(}(=>(y5=u9s-5{JXV5hyqc1>+%LtPnpoF&O5@QvadB z9AJ@|GzObS_fy%>NF>n%*hXqRZ~vs>%lN6*kM%uNyr98@i3~VW2eCox8<0Z&iDLvX zeZCo|kl}z2;0yS%Sv)NACzjz&XVY2U^uJO4^YQ;MzzeUf?N1wj>5H%LPZKP*MIevH z_kjE*n&lG00N_pliypuv0~UciH`O=TFz{wffXJpZUFdY5AIWm~;j#(}qk~d`*b>P! zzYP*v|0V*M6WM@~+D6`B2;Lj%f<)qVk$42g{ZEuFokF9A{9hCbuZzJW(LYeUa-k5} z#Q%h&knvPH)0fEONb@Cn0dR(&mzv7Y$l=ZCK6EC}9M2u}H`}(h_@zn~ z=IkQ7}UnyB|f-keSj*huCNc?wS+ zNx`A@(PT0VLFSbhhJ@9H;V5J(j6~JPBFI=heLb@N4|#hgjklnPKL53zjb*k`1$b*3 ziQ~KBW{S(2W zQ`td8CSdBt3)DX-9{AsR&LRf>XTOPPJt`VU!NJH_A_9g%VM#C&j)a8)Xn>4HA@zx5 zJznJh&TkZ+SDNUJy8Q3_{w_xLCi-~+yuA^w_TMx4XEpfuOn$7L|4%0HjlJ;C!h-+r zn)~+b-}X&j3cpS9Hsg)q&y#?6^YbhL{CGiP@=l6a?)Fz8koci}=B6&e=V$Xms(<}e zsm=JkPATLSM>r|^6-VPtcJAKTOIUCxE3@oviVmM|*{O`8OQ}>GMHJ0CfOhPFZj)|~ z+L}S&@QXlpmx^zr1T$90!k&gs=And_!Jyjy5BFUqCNJ!Y{CfW#Ws@rdk|{;IEU% z>fIUJL(m9LqzQvOncXIgHk)zA-Gqger3RV%1DTw_4G zLD+3}UZNj+ge|z5b}AxOJgbFNM!qD_59eIj-J1o)n(R1Y_YL>RDx15ij3_t}@o>NT z`=vLBdHPNZ2v>MU-IkuSQEp7Ap}9c=Y*M;Ek4n#4M9##YcZ12;D3K%yV^Oo(FH;re z_-7nv?Fi7!^*^pe8%60m*XkD0OAQ3Dk_3gtkF{Kp4B(BfM%{GY7p@$){>{WPcab|<-d{Kb zh>x>hx~#t_u6ro}wd?BK2k}e~J1pZ6T6~N(1!&(hxc|FNt%pl8e;Z{s<+KDh0&@H} z`4MnvQRzz9tM!Ztf#5B5${(#_^p56mzMiop0X`x6D#8P8C-2n}HoJ^-?&N4$8*I zWG|O5G9Gn`LbNdXlqEcjOl%#o9k7J;C}5>MkEgs_P-~LCt9@OsL~k^S#WCY}Irra9 z7zb;^bu5o^OLymurX?KXT#W$4(>;&4y}1Er6kVH94F^BY(=^Qc=zr=gRpimu)R;m? z_jgB;l7nH;nx_UaH+7}wTU=#(tl6=5xk|)5r`9BogoGpov9+OXk7d=R5$#ox8VvVG zi{gCB)^5)M{`Y2-adw|`384vx*!nTjx_3a z@vD`0>-?E*6UJ%b0{wZaYhMLkoN{sf+A*za;#EBvQxu3C%y?G+k&sr^?ywwtH=iRm z{MKcYf|R0xVq$Tm@4yOlN}xY~Tflm?X=gRDr^`9D=c<;}G5QhrKfWq+??L*i_^bKf z6%1N%jH0Ac^BZ=^&7a)cQH$3ky7ebcd_+Np7GDa11$5U zFZ8RPY`FNiyWDQJTXf=@L`10|p0BN^;?6dXc=x=l`HN8+#GUU-EOjNJ&AWDSvnhXa zWV>>meA!Ss$JjY{B;55wc%`Kt)R;eY?La}DQSunpIADQzX$-r)c3QG_G0?!IyKAR{>U$Y444eID725^}{m*CdHk0N~co=38l|K3K^h0j4 zdeYP(pXu;7Fd-+@v@;{J|h`>ytYJvZU4uL;|FkDL6l*npfi{gUHrYxxBs zPgeGlkIn6c!TpbytVCQ?NHyqHe05G@V)9hA*tJnY?;VF{C1Ceg)tLxSL+iVps@y=i z-|HXbAVru&!mCfC8OrJq zTCI;am`+T^HT6)DtFMcs+|2=%o3Pfm$Aq6swOF*@9;7c~y3K1Hs!ES3uLLb*`C67~ z@7?Z6%c(6lJ5di(66rmZUp0Di2q+Qr4SeYET+=7|mXS#6cJF~(35;`z7dN*T*FIV0xnilqOL-=NOz!yyD{duXI3lI7qu6cRn!UJf< zZ!#-S_QG$QVv{fBE6ofTIa(}goye=3yi189>}j%n+khL-bo#vT_Wj=W~=q0wmXZ2iPU=}mp zur;sO%WpDI{UJAwT%oPCxpAqs#(+9}BqeSV1tT5D z4Q?A~bC|n${r%$62a!D|Gg|yse|;UB5>Z^yVygGhRt%W^EGURMcJ^0b_b#*4!%i}3 zLb=qpz~$UViN%kl)5ctfcgI;dh?XOB(czTLOK!%-Rl*lOGRmlR&d2xD$CG1atZQ1a zl$BVKnIQGuP4R=SWkYOrEYakAY>xT16vrzEd!v56Wc=wejn+|+STnil{ZP5b#O<@~ zxcSYO?KGDvlJ`rHm`i8W#f*jaEcVe&^eT(86EDrTdf&?RAw%NVNTr&Unu8IywQIDl z-#>%q&#k-HGM@exO}7Inca&YN&?5I2o&%8gz|)Hf6TdWnD2^j%9pi@g)a`L84(z$S zP^PhSsmVORTkwNG@h? zhC`a!Vm|UXWI^-c2*s4G>b8H_FeKHf#3fyIB$?Llqw^$cV5jMb6fQgFQ&NtA_aSrP z>YR47p=p<4xt5$5IZlrQTs$=6s0>h5A)|?|7fdUn**1%(1Rbl%-ds`s=F{RUnwgtb zM(3v!9K0vzM$AH+;n$}ny{&?|ZZC{48=KDYM;>URozCx&*(tPjR;6)^Fyk(_Njf<0 zNK}xFWItSRMt@KuJtnCaTs(i{pn0D9{Ud_~?q?1!bx^Kn( z^CR!VF~j34AC;rm(+(7f2L*o#LckSo9KO-maIH~xAv0N_!OLmhPS9@otaQrp0hKBM zrPT0=%sKc=!j7LeEQ=A($iiq(^s1?M_tuVb4dpe(!?z@6u1Y8d>18^dd;2tX@!~}Z zZ^zV!?S8$YZ_DlPH*yx%9PfIm^v!|NTEgs1)h&*@&l@@J&XI$hJ+O;HWq92imR(Uw zwj_^)^^C7F)$22dFUj3`ExbyAWvU#!au}NjR*arYho@``g#pdFnu3#2n9FjLL56+O z1S!ZxZuw4|?#&+J;Yq#u4`U(@790>D9=NA?w|1*A%K3~PKf~K3EK*inDt+(rQrkMW zV%Nxlx55ye>s}{nO$$2}Qn(8!C8KSTjf27Z1q*v3_PL#kenyOm@T4UbR_LzZe&i9_ zUtDTdt#kEuYuRMsqrJ$jUj>FYF`oRxpjmz{M9g;WaZYLmkq-+NPKG}YQ7 zwi%DfSqwk1Z2Eb}e%jY&NzHnSn|4*2N7}0a1H_eqvs7WHgU#5-YcXPv(z(`k^E(KBSix{)xt8bFe^3iMhl58 zz7VJBRTa})@`K$kwmo$0yd!>TI#BZFl>nXo5b2`rkw%xAVg;)kf2)+hO;LI&8lV?z zH({^AG)bo{dx55tl~bYPj}{a||F~T>kyzb0p@3-PvoqJbvvgrzVmU$gna_Pj+-#>X zE>gGt;%U`It7OtAutvM`a^<>MT$g>rho!>oSqM2*r2;dY^0N6Q=|=C?$S6Vi-Ng?p z>%c`3ORLUU?-E|N* z<~S`REpgK-_(UpHJme|^w-8uMfCik|r^%i=;p0?u{>9VBM%Bv3a`7!`ClK~;0Z7l@ z-V7$}y~b|@UUy^f;@#piKIVMY(7``AZs?5OEdmtS**U~SJDB*^FilS(qk=;Ucpae9 z)*xg@2v>Rhq6$n@9qA><`|%vGKdmp!HNPuP`%!OJU56P_$}Q{5O#<|40_Jhz;rM4p z(M4OdHrYZ1ANlp#KibaofP=UdgH=p&sbW&09w(nYcp=c}+PcVNDZlFqmnj!Lv#;49 zI!w7^)11X1|L;gwUhlTg`VjbWk<@;`AN%NBwQP5MbUoB6q`gG#Zn@!a^*4B4n2&%T z+wkvi?dJ!42INDY&=Z(SSNHDyT|J|C#dg8XxQiELt*@Kv%TZ3h!FEk-{Gq$g!p^*M I?~xP#0(| - - We're live on{" "} - - Product Hunt - - - Vote for us today only! - -
- ); -} diff --git a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx index e6362d4cb1d..125ae688dfb 100644 --- a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx +++ b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx @@ -20,6 +20,7 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; import type { BillingLimitResult } from "~/services/billingLimit.schemas"; import { formatCurrency } from "~/utils/numberFormatter"; +import { TextLink } from "~/components/primitives/TextLink"; export const billingLimitFormSchema = z.discriminatedUnion("mode", [ z.object({ @@ -338,10 +339,7 @@ function LimitReachedCalloutContent({ When this limit is reached, queued runs will be held for {gracePeriodLabel}, then new triggers will be rejected until you increase or remove the limit. Limits are enforced with a short delay, so spend may briefly exceed the limit before grace begins. See our{" "} - - terms - {" "} - for refund policy details. + terms for refund policy details. {cancelInProgressRuns ? ( <> In-progress runs will be cancelled when the limit is hit. ) : null} diff --git a/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx b/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx index 77aea48065a..04308b6263d 100644 --- a/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx +++ b/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx @@ -7,6 +7,7 @@ import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { useOptionalProject } from "~/hooks/useProject"; import { cn } from "~/utils/cn"; import { v3RunPath } from "~/utils/pathBuilder"; +import { textLinkClassName } from "~/components/primitives/TextLink"; // The "why did this run fail?" failure card — the first block in the dashboard // agent's view catalog. Rendered from a `diagnosis` block the agent emits via @@ -60,7 +61,7 @@ function RunLink({ runId, className }: { runId: string; className?: string }) { const to = useRunPath(runId); if (!to) return {runId}; return ( - + {runId} ); @@ -80,7 +81,7 @@ function EvidenceReference({ reference }: { reference: string }) { href={safeUrl} target="_blank" rel="noopener noreferrer" - className="font-mono text-xs text-indigo-400 underline hover:text-indigo-300" + className={cn(textLinkClassName(), "font-mono text-xs")} > {reference} diff --git a/apps/webapp/app/components/navigation/NotificationCard.tsx b/apps/webapp/app/components/navigation/NotificationCard.tsx index e9ad1e07e65..2274138b3e4 100644 --- a/apps/webapp/app/components/navigation/NotificationCard.tsx +++ b/apps/webapp/app/components/navigation/NotificationCard.tsx @@ -2,6 +2,7 @@ import { XMarkIcon } from "@heroicons/react/20/solid"; import { useLayoutEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import { cn } from "~/utils/cn"; +import { textLinkClassName } from "~/components/primitives/TextLink"; export function NotificationCard({ title, @@ -109,7 +110,7 @@ function getMarkdownComponents(onLinkClick?: () => void) { href={href} target="_blank" rel="noopener noreferrer" - className="relative z-20 text-indigo-400 underline transition-colors hover:text-indigo-300" + className={cn(textLinkClassName(), "relative z-20")} onClick={(e) => { e.stopPropagation(); onLinkClick?.(); diff --git a/apps/webapp/app/components/primitives/LabelValueStack.tsx b/apps/webapp/app/components/primitives/LabelValueStack.tsx index 977ef6ee84c..15411ab3cbc 100644 --- a/apps/webapp/app/components/primitives/LabelValueStack.tsx +++ b/apps/webapp/app/components/primitives/LabelValueStack.tsx @@ -1,8 +1,8 @@ import { cn } from "~/utils/cn"; import { Paragraph } from "./Paragraph"; +import { TextLink } from "./TextLink"; import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import { SimpleTooltip } from "./Tooltip"; -import { Link } from "@remix-run/react"; const variations = { primary: { @@ -69,9 +69,9 @@ function ValueButton({ value, href, variant = "secondary" }: ValueButtonStackPro if (!isExternalUrl) { return ( - + {value} - + ); } @@ -81,10 +81,14 @@ function ValueButton({ value, href, variant = "secondary" }: ValueButtonStackPro side="bottom" button={ - + {value} - - + } content={href} diff --git a/apps/webapp/app/components/primitives/TextLink.tsx b/apps/webapp/app/components/primitives/TextLink.tsx index 65ba201c6fd..61c2d5ee5fc 100644 --- a/apps/webapp/app/components/primitives/TextLink.tsx +++ b/apps/webapp/app/components/primitives/TextLink.tsx @@ -6,14 +6,26 @@ import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKey import { ShortcutKey } from "./ShortcutKey"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip"; -// inline-text-link: marker for the "Underline links" preference, which underlines -// these and nothing else (nav items, buttons-as-links and decorative underlines -// all stay put). See tailwind.css. -const base = "inline-text-link inline-flex gap-0.5 items-center group focus-visible:focus-custom"; +const colors = { + primary: "text-indigo-500 transition hover:text-indigo-400", + secondary: "text-text-dimmed transition hover:text-text-bright", +} as const; + +/** + * A link's colour plus `inline-text-link`, the marker the "Underline links" + * preference targets (see tailwind.css) - without this component's layout. + * + * For links that can't be a `TextLink`: ones that must stay in the inline flow + * (markdown prose, where the component's inline-flex would stop them wrapping), + * and triggers that aren't anchors at all. + */ +export function textLinkClassName(variant: keyof typeof colors = "primary") { + return cn("inline-text-link focus-visible:focus-custom", colors[variant]); +} const variations = { - primary: `${base} text-indigo-500 transition hover:text-indigo-400`, - secondary: `${base} text-text-dimmed transition hover:text-text-bright`, + primary: cn(textLinkClassName("primary"), "inline-flex gap-0.5 items-center group"), + secondary: cn(textLinkClassName("secondary"), "inline-flex gap-0.5 items-center group"), } as const; type TextLinkProps = { @@ -27,6 +39,8 @@ type TextLinkProps = { shortcut?: ShortcutDefinition; hideShortcutKey?: boolean; tooltip?: React.ReactNode; + /** Forwarded to `Link`: forces a full document load rather than a client nav. */ + reloadDocument?: boolean; } & React.AnchorHTMLAttributes; export function TextLink({ @@ -40,6 +54,7 @@ export function TextLink({ shortcut, hideShortcutKey, tooltip, + reloadDocument, ...props }: TextLinkProps) { const innerRef = useRef(null); @@ -69,7 +84,13 @@ export function TextLink({ ); const linkElement = to ? ( - + {linkContent} ) : href ? ( diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index e696f202559..1932087f3e2 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -2,6 +2,7 @@ import type { UIMessage } from "@ai-sdk/react"; import { memo } from "react"; import { AssistantResponse, ChatBubble, ToolUseRow } from "~/components/runs/v3/ai/AIChatMessages"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; +import { textLinkClassName } from "~/components/primitives/TextLink"; // --------------------------------------------------------------------------- // AgentMessageView — renders an AI SDK UIMessage[] conversation. @@ -215,12 +216,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { } return ( @@ -270,12 +266,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { } return ( diff --git a/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx b/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx index f7b09b6daf1..92a5c16be60 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; import { CodeBlock } from "~/components/code/CodeBlock"; import type { AISpanData, ToolDefinition } from "./types"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { textLinkClassName } from "~/components/primitives/TextLink"; +import { cn } from "~/utils/cn"; export function AIToolsInventory({ aiData }: { aiData: AISpanData }) { const defs = aiData.toolDefinitions ?? []; @@ -48,7 +50,7 @@ function ToolDefRow({ def, wasCalled }: { def: ToolDefinition; wasCalled: boolea
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 73e1752715b..79556e734b0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -1,4 +1,4 @@ -import { Link, useLocation } from "@remix-run/react"; +import { useLocation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { useEffect, useState, useRef, useCallback } from "react"; @@ -53,6 +53,7 @@ import { capitalizeWord } from "~/utils/string"; import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route"; import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas"; import { pageMeta } from "~/utils/pageTitle"; +import { TextLink } from "~/components/primitives/TextLink"; export const meta = pageMeta(({ params }) => [ params.deploymentParam ?? "Deployment", @@ -322,12 +323,12 @@ export default function Page() { Build Server - {deployment.externalBuildData.buildId} - + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 822050f19f9..bf88b35d334 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -80,6 +80,7 @@ import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { env } from "~/env.server"; import { DialogClose } from "@radix-ui/react-dialog"; import { pageMeta } from "~/utils/pageTitle"; +import { TextLink } from "~/components/primitives/TextLink"; export const meta = pageMeta("Deployments"); @@ -377,14 +378,18 @@ export default function Page() { {environmentGitHubBranch}
{" "} in - - {connectedGithubRepository.repository.fullName} - + {/* truncate needs a block-level child: the link itself is inline-flex */} + + {connectedGithubRepository.repository.fullName} + + {/* Table of contents */} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 7e37e9817c3..3bceb691e6c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -57,6 +57,7 @@ import { import { SetSeatsAddOnService } from "~/v3/services/setSeatsAddOn.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { pageMeta } from "~/utils/pageTitle"; +import { TextLink } from "~/components/primitives/TextLink"; export const meta = pageMeta("Team"); @@ -465,12 +466,9 @@ export default function Page() {
Active team members {roles.length > 0 ? ( - - View all role permissions → - + + View all role permissions + ) : null}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index cab7b8b41b2..14b12fa6369 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -29,7 +29,7 @@ import { Header2 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; import { TextArea } from "~/components/primitives/TextArea"; -import { TextLink } from "~/components/primitives/TextLink"; +import { TextLink, textLinkClassName } from "~/components/primitives/TextLink"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage } from "~/models/message.server"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; @@ -602,9 +602,7 @@ export function TierHobby({ - Request a BAA - + Request a BAA } /> @@ -756,9 +754,7 @@ export function TierPro({ - Request a BAA - + Request a BAA } /> @@ -816,9 +812,7 @@ export function TierEnterprise() { - Request a BAA - + Request a BAA } />