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/assets/images/producthunt.png b/apps/webapp/app/assets/images/producthunt.png
deleted file mode 100644
index e27a96f6976..00000000000
Binary files a/apps/webapp/app/assets/images/producthunt.png and /dev/null differ
diff --git a/apps/webapp/app/components/ProductHuntBanner.tsx b/apps/webapp/app/components/ProductHuntBanner.tsx
deleted file mode 100644
index abb5a146355..00000000000
--- a/apps/webapp/app/components/ProductHuntBanner.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import productHuntLogo from "../assets/images/producthunt.png";
-import { ArrowRightIcon } from "@heroicons/react/20/solid";
-import { Paragraph } from "./primitives/Paragraph";
-import { LinkButton } from "./primitives/Buttons";
-
-export function ProductHuntBanner() {
- return (
-
-
- We're live on{" "}
-
-
-
-
- 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/AppearanceMenuItem.tsx b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx
new file mode 100644
index 00000000000..7890729359e
--- /dev/null
+++ b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx
@@ -0,0 +1,82 @@
+import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
+import { useFetcher } from "@remix-run/react";
+import { useEffect } from "react";
+import { useTypedRouteLoaderData } from "remix-typedjson";
+import { ToggleSwitchIcon } from "~/assets/icons/ToggleSwitchIcon";
+import { PopoverMenuItem } from "~/components/primitives/Popover";
+import { THEME_OPTIONS } from "~/components/themeOptions";
+import { applyThemePreference } from "~/hooks/useSystemThemeSync";
+import { type loader as rootLoader } from "~/root";
+import { accountPath } from "~/utils/pathBuilder";
+import { normalizeThemePreference, type ThemePreference } from "~/utils/themePreference";
+import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu";
+import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes";
+
+const THEME_ACTION_PATH = "/resources/preferences/theme";
+
+/**
+ * Theme switcher for the account popover: an "Appearance" submenu listing each theme, with a check
+ * against the current one. Picking a theme doesn't navigate, so the menu stays open and the new
+ * theme applies underneath it. Hidden entirely while the theme switcher feature flag is off,
+ * matching the account page.
+ */
+export function AppearanceMenuItem() {
+ const rootData = useTypedRouteLoaderData("root");
+ const fetcher = useFetcher<{ success?: boolean }>();
+ const savedTheme = rootData?.themePreference;
+
+ // A failed write would otherwise leave the optimistic theme on screen, since
+ // the loader data never changes and so `useSystemThemeSync` never re-runs.
+ useEffect(() => {
+ if (fetcher.state !== "idle" || !fetcher.data || fetcher.data.success || !savedTheme) return;
+ applyThemePreference(savedTheme);
+ }, [fetcher.state, fetcher.data, savedTheme]);
+
+ if (!rootData?.showThemeSwitcher) {
+ return null;
+ }
+
+ // Move the check as soon as a theme is clicked; the write follows.
+ const pendingTheme = fetcher.formData?.get("theme");
+ const theme =
+ typeof pendingTheme === "string"
+ ? normalizeThemePreference(pendingTheme)
+ : rootData.themePreference;
+
+ const pickTheme = (value: ThemePreference) => {
+ // Applied here rather than waiting for the write to come back through the
+ // root loader: dismissing the popover unmounts this row, and an unmounted
+ // fetcher's revalidation is dropped, which left the theme untouched even
+ // though the preference had saved.
+ applyThemePreference(value);
+ fetcher.submit({ theme: value }, { method: "post", action: THEME_ACTION_PATH });
+ };
+
+ return (
+ // Much narrower than the standard submenu: these labels don't need the room.
+
+
{title}
diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx
index e3ab6cdcd4e..a215a2939ea 100644
--- a/apps/webapp/app/components/primitives/Slider.tsx
+++ b/apps/webapp/app/components/primitives/Slider.tsx
@@ -1,8 +1,9 @@
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";
+import { SimpleTooltip } from "./Tooltip";
const variants = {
/* Quiet variant for settings rows: no hover box, no thumb halo */
@@ -12,10 +13,16 @@ const variants = {
root: "h-4 grow",
track: "h-1 bg-grid-bright",
range: "bg-transparent",
- // Matches the Switch thumb; 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-3 w-3 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).
+ 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",
@@ -25,6 +32,9 @@ 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,
+ mark: "bg-grid-bright border-background-dimmed",
+ markHover: "hover:bg-text-dimmed",
},
};
@@ -34,6 +44,22 @@ export type SliderProps = ComponentProps & {
LeadingIcon?: RenderIcon;
TrailingIcon?: RenderIcon;
variant: VariantName;
+ /**
+ * 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;
+ /** Values to tick on the track, e.g. the setting's default. */
+ 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({
@@ -42,9 +68,18 @@ export function Slider({
LeadingIcon,
TrailingIcon,
"aria-label": ariaLabel,
+ valueTooltip,
+ marks,
...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;
+ const min = props.min ?? 0;
+ const max = props.max ?? 100;
+
return (
{LeadingIcon && }
@@ -55,18 +90,93 @@ 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);
+ }}
>
+ {marks?.map((mark) => {
+ 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}
+ />
+ }
+ />
+ );
+ })}
{/* The thumb is the role="slider" element, so the label lives here */}
+ >
+ {valueTooltip && (
+
+ {valueTooltip(currentValue)}
+ {/* Straddles the bottom edge, hiding the border it overlaps, so the
+ two outer sides read as an arrow pointing at the handle. */}
+
+
+ )}
+
{TrailingIcon && }
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/components/primitives/TextLink.tsx b/apps/webapp/app/components/primitives/TextLink.tsx
index d0186268c0c..61c2d5ee5fc 100644
--- a/apps/webapp/app/components/primitives/TextLink.tsx
+++ b/apps/webapp/app/components/primitives/TextLink.tsx
@@ -6,11 +6,26 @@ import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKey
import { ShortcutKey } from "./ShortcutKey";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
+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:
- "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: 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 = {
@@ -24,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({
@@ -37,6 +54,7 @@ export function TextLink({
shortcut,
hideShortcutKey,
tooltip,
+ reloadDocument,
...props
}: TextLinkProps) {
const innerRef = useRef(null);
@@ -66,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 (
+
+
+
+
+ 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!
+
+
+
+ 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",
diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts
index 772ba338ac6..87a73cc4015 100644
--- a/apps/webapp/app/services/dashboardPreferences.server.ts
+++ b/apps/webapp/app/services/dashboardPreferences.server.ts
@@ -178,6 +178,66 @@ 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 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 174a9a9ff5e..dad3c873e32 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,35 @@
--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) {
+/* "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
+ 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 +437,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 +804,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 +893,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..45606749ff4 100644
--- a/apps/webapp/app/utils/dashboardPreferences.ts
+++ b/apps/webapp/app/utils/dashboardPreferences.ts
@@ -52,6 +52,10 @@ 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),
+ /** 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 2b6408c2abc..f212336a3c6 100644
--- a/apps/webapp/app/utils/themePreference.ts
+++ b/apps/webapp/app/utils/themePreference.ts
@@ -16,6 +16,17 @@ 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;
+}
+
+/** 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 {