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/CircleFilledIcon.tsx b/apps/webapp/app/assets/icons/CircleFilledIcon.tsx
new file mode 100644
index 00000000000..a6d10485bee
--- /dev/null
+++ b/apps/webapp/app/assets/icons/CircleFilledIcon.tsx
@@ -0,0 +1,16 @@
+/** Solid circle. Paired with {@link CircleOutlineIcon} by the Black and White
+ * theme options — the filled disc reads as the opposite of the active theme. */
+export function CircleFilledIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx b/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx
new file mode 100644
index 00000000000..e60de21907f
--- /dev/null
+++ b/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx
@@ -0,0 +1,16 @@
+/** Hollow circle. Paired with {@link CircleFilledIcon} by the Black and White
+ * theme options, which show the active theme's background through the ring. */
+export function CircleOutlineIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/assets/icons/MonitorIcon.tsx b/apps/webapp/app/assets/icons/MonitorIcon.tsx
new file mode 100644
index 00000000000..09aae279843
--- /dev/null
+++ b/apps/webapp/app/assets/icons/MonitorIcon.tsx
@@ -0,0 +1,21 @@
+/** Monitor on a stand — the System theme, which follows the OS appearance. */
+export function MonitorIcon({ 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 6f2102f8efe..daf36f5cb52 100644
--- a/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx
+++ b/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx
@@ -101,7 +101,7 @@ function RunLink({ runId, className }: { runId: string; className?: string }) {
const to = useRunPath(runId);
if (!to) return {runId};
return (
-
+
{runId}
);
@@ -119,7 +119,7 @@ function EvidenceReference({ reference }: { reference: string }) {
variant="token"
target="_blank"
rel="noopener noreferrer"
- className="font-mono text-xs underline"
+ className="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..3b9a92c9e4c
--- /dev/null
+++ b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx
@@ -0,0 +1,83 @@
+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;
+ const systemThemes = rootData?.systemThemes;
+
+ // 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, systemThemes);
+ }, [fetcher.state, fetcher.data, savedTheme, systemThemes]);
+
+ 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, rootData.systemThemes);
+ 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 5f32fcdf850..24d93712ca3 100644
--- a/apps/webapp/app/components/primitives/TextLink.tsx
+++ b/apps/webapp/app/components/primitives/TextLink.tsx
@@ -6,15 +6,32 @@ import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKey
import { ShortcutKey } from "./ShortcutKey";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
-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",
+const colors = {
+ primary: "text-indigo-500 transition hover:text-indigo-400",
+ secondary: "text-text-dimmed transition hover:text-text-bright",
// The theme-remapped link token, for links inside themed surfaces where the
// raw indigo of `primary` is dark-theme only.
- token:
- "text-text-link transition hover:underline inline-flex gap-0.5 items-center group focus-visible:focus-custom",
+ token: "text-text-link transition hover:underline",
+} as const;
+
+const layout = "inline-flex gap-0.5 items-center group";
+
+/**
+ * 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: cn(textLinkClassName("primary"), layout),
+ secondary: cn(textLinkClassName("secondary"), layout),
+ token: cn(textLinkClassName("token"), layout),
} as const;
type TextLinkProps = {
@@ -28,6 +45,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({
@@ -41,6 +60,7 @@ export function TextLink({
shortcut,
hideShortcutKey,
tooltip,
+ reloadDocument,
...props
}: TextLinkProps) {
const innerRef = useRef(null);
@@ -70,7 +90,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 81a45edef2d..44009377f65 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.
@@ -219,12 +220,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/components/themeOptions.ts b/apps/webapp/app/components/themeOptions.ts
new file mode 100644
index 00000000000..c92ae6632b5
--- /dev/null
+++ b/apps/webapp/app/components/themeOptions.ts
@@ -0,0 +1,73 @@
+import { SwatchIcon } from "@heroicons/react/24/outline";
+import { type FunctionComponent } from "react";
+import { CircleFilledIcon } from "~/assets/icons/CircleFilledIcon";
+import { CircleOutlineIcon } from "~/assets/icons/CircleOutlineIcon";
+import { MonitorIcon } from "~/assets/icons/MonitorIcon";
+import { MoonIcon } from "~/assets/icons/MoonIcon";
+import { SunIcon } from "~/assets/icons/SunIcon";
+import { type ThemeAppearance } from "~/hooks/useSystemThemeSync";
+import { type ThemePreference } from "~/utils/themePreference";
+
+export type ThemeOption = {
+ value: ThemePreference;
+ label: string;
+ icon: FunctionComponent<{ className?: string }>;
+};
+
+/** The themes offered everywhere, in display order - including the account
+ * popover's submenu. Shared by every theme picker so the labels and icons
+ * can't drift apart. */
+export const THEME_OPTIONS: ThemeOption[] = [
+ { value: "system", label: "System", icon: MonitorIcon },
+ { value: "light", label: "Light", icon: SunIcon },
+ { value: "dark", label: "Dark", icon: MoonIcon },
+];
+
+/** Dark and Light with their surfaces pinned flat, so grid lines carry the
+ * layout. Account page only, alongside Classic. The icons here are the
+ * dark-theme pair; `themeOptionIcon` swaps them per active theme. */
+const FLAT_OPTIONS: ThemeOption[] = [
+ { value: "black", label: "Black", icon: CircleOutlineIcon },
+ { value: "white", label: "White", icon: CircleFilledIcon },
+];
+
+/** Legacy theme, offered on the account page only. */
+export const CLASSIC_OPTION: ThemeOption = {
+ value: "classic",
+ label: "Classic",
+ icon: SwatchIcon,
+};
+
+/** Every theme, for the account page's full picker. */
+export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, ...FLAT_OPTIONS, CLASSIC_OPTION];
+
+export const THEME_OPTIONS_BY_VALUE = Object.fromEntries(
+ ALL_THEME_OPTIONS.map((option) => [option.value, option])
+) as Record;
+
+/**
+ * The icon to draw for an option under the active theme.
+ *
+ * Black and White show the active theme's background *through* the circle: the
+ * option matching the current end of the scale is a ring, so the background
+ * reads through it, and the opposing one is a solid disc in the foreground
+ * colour. On a dark theme that makes Black a ring and White a filled disc; on a
+ * light theme it flips. Every other option has one fixed icon.
+ */
+export function themeOptionIcon(option: ThemeOption, appearance: ThemeAppearance) {
+ if (option.value === "black") {
+ return appearance === "dark" ? CircleOutlineIcon : CircleFilledIcon;
+ }
+ if (option.value === "white") {
+ return appearance === "light" ? CircleOutlineIcon : CircleFilledIcon;
+ }
+ return option.icon;
+}
+
+/** The two candidates for each end of the `system` setting. */
+export const SYSTEM_LIGHT_OPTIONS: ThemeOption[] = ALL_THEME_OPTIONS.filter(
+ (option) => option.value === "light" || option.value === "white"
+);
+export const SYSTEM_DARK_OPTIONS: ThemeOption[] = ALL_THEME_OPTIONS.filter(
+ (option) => option.value === "dark" || option.value === "black"
+);
diff --git a/apps/webapp/app/hooks/useSystemThemeSync.ts b/apps/webapp/app/hooks/useSystemThemeSync.ts
index 6b2a678396f..13f18d98b5d 100644
--- a/apps/webapp/app/hooks/useSystemThemeSync.ts
+++ b/apps/webapp/app/hooks/useSystemThemeSync.ts
@@ -1,5 +1,80 @@
-import { useEffect } from "react";
-import { type ThemePreference } from "~/utils/themePreference";
+import { useEffect, useState } from "react";
+import {
+ type SystemDarkTheme,
+ type SystemLightTheme,
+ type ThemePreference,
+} from "~/utils/themePreference";
+
+/** Which theme `system` lands on at each end of the OS setting. */
+export type SystemThemes = { light: SystemLightTheme; dark: SystemDarkTheme };
+
+export const DEFAULT_SYSTEM_THEMES: SystemThemes = { light: "light", dark: "dark" };
+
+/** Which end of the scale a theme sits on. Classic and Black are dark; White is
+ * light; `system` follows the OS. */
+export type ThemeAppearance = "dark" | "light";
+
+export function themeAppearance(
+ preference: ThemePreference,
+ prefersDark: boolean
+): ThemeAppearance {
+ if (preference === "system") return prefersDark ? "dark" : "light";
+ return preference === "light" || preference === "white" ? "light" : "dark";
+}
+
+/**
+ * The resolved appearance, tracking OS changes while the preference is `system`.
+ *
+ * Defaults to dark before the effect runs, matching the SSR fallback in root.tsx,
+ * so the first client render agrees with the server's.
+ */
+export function useThemeAppearance(preference: ThemePreference): ThemeAppearance {
+ const [prefersDark, setPrefersDark] = useState(true);
+
+ useEffect(() => {
+ if (preference !== "system") return;
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
+ const apply = () => setPrefersDark(media.matches);
+ apply();
+ media.addEventListener("change", apply);
+ return () => media.removeEventListener("change", apply);
+ }, [preference]);
+
+ return themeAppearance(preference, prefersDark);
+}
+
+/**
+ * The theme a preference resolves to. Only `system` needs resolving, and it lands
+ * on whichever variant the user picked for that end of the OS setting - Light or
+ * White, Dark or Black.
+ */
+export function resolveThemePreference(
+ preference: ThemePreference,
+ prefersDark: boolean,
+ systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES
+): ThemePreference {
+ if (preference !== "system") return preference;
+ return prefersDark ? systemThemes.dark : systemThemes.light;
+}
+
+/**
+ * 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,
+ systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES
+) {
+ const prefersDark =
+ preference === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches;
+ document.documentElement.setAttribute(
+ "data-theme",
+ resolveThemePreference(preference, prefersDark, systemThemes)
+ );
+ document.documentElement.setAttribute("data-theme-preference", preference);
+}
/**
* Keeps `data-theme` on in sync with the preference. For `system` it
@@ -10,20 +85,26 @@ import { type ThemePreference } from "~/utils/themePreference";
* inline script in root.tsx; downstream consumers react to the `data-theme`
* mutation (see useThemeColor).
*/
-export function useSystemThemeSync(preference: ThemePreference) {
+export function useSystemThemeSync(
+ preference: ThemePreference,
+ systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES
+) {
+ const { light, dark } = systemThemes;
+
useEffect(() => {
if (preference !== "system") {
- document.documentElement.setAttribute("data-theme", preference);
+ applyThemePreference(preference);
return;
}
const media = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
- document.documentElement.setAttribute("data-theme", media.matches ? "dark" : "light");
+ document.documentElement.setAttribute("data-theme", media.matches ? dark : light);
};
apply();
media.addEventListener("change", apply);
return () => media.removeEventListener("change", apply);
- }, [preference]);
+ // Destructured so a fresh object identity each render doesn't re-run this
+ }, [preference, light, dark]);
}
diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx
index 1e550155fde..14033dedd27 100644
--- a/apps/webapp/app/root.tsx
+++ b/apps/webapp/app/root.tsx
@@ -20,11 +20,15 @@ import { TimezoneSetter } from "./components/TimezoneSetter";
import { env } from "./env.server";
import { featuresForRequest } from "./features.server";
import { usePostHog } from "./hooks/usePostHog";
-import { useSystemThemeSync } from "./hooks/useSystemThemeSync";
+import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync";
import { getImpersonationState } from "./services/impersonation.server";
import { getUser } from "./services/session.server";
import {
+ normalizeIconContrast,
+ normalizeSystemDarkTheme,
+ normalizeSystemLightTheme,
normalizeThemeContrast,
+ normalizeUnderlineLinks,
normalizeThemePreference,
type ThemePreference,
} from "~/utils/themePreference";
@@ -95,6 +99,19 @@ 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;
+ const underlineLinks = showThemeSwitcher
+ ? normalizeUnderlineLinks(user?.dashboardPreferences.underlineLinks)
+ : false;
+ // Which theme `system` lands on at each end of the OS setting.
+ const systemThemes = {
+ light: normalizeSystemLightTheme(user?.dashboardPreferences.systemLightTheme),
+ dark: normalizeSystemDarkTheme(user?.dashboardPreferences.systemDarkTheme),
+ };
// 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.
@@ -124,7 +141,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
kapa,
timezone,
showThemeSwitcher,
+ iconContrast,
+ underlineLinks,
themePreference,
+ systemThemes,
themeContrast,
// Consumed by ResizablePanel: the browser check must match between SSR
// and hydration, so it is derived from the request user-agent.
@@ -171,13 +191,20 @@ export function ErrorBoundary() {
}
export default function App() {
- const { posthogProjectKey, posthogUiHost, themePreference, themeContrast } =
- useTypedLoaderData();
+ const {
+ posthogProjectKey,
+ posthogUiHost,
+ themePreference,
+ themeContrast,
+ iconContrast,
+ underlineLinks,
+ systemThemes,
+ } = useTypedLoaderData();
usePostHog(posthogProjectKey, posthogUiHost);
- useSystemThemeSync(themePreference);
- // SSR falls back to dark for `system`; the inline script below corrects it
- // before paint, and useSystemThemeSync keeps it live afterwards.
- const resolvedTheme = themePreference === "system" ? "dark" : themePreference;
+ useSystemThemeSync(themePreference, systemThemes);
+ // SSR falls back to the dark end for `system`; the inline script below corrects
+ // it before paint, and useSystemThemeSync keeps it live afterwards.
+ const resolvedTheme = resolveThemePreference(themePreference, true, systemThemes);
return (
<>
@@ -188,13 +215,21 @@ export default function App() {
suppressHydrationWarning
data-theme={resolvedTheme}
data-theme-preference={themePreference}
+ // Read by the pre-paint script below, which resolves `system` before the
+ // loader data is available to JS
+ data-system-light={systemThemes.light}
+ data-system-dark={systemThemes.dark}
+ // 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/_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 320a3e66d0c..ee2458124a5 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";
@@ -59,6 +59,7 @@ export const handle: Handle = {
agentPageContext: (data) => deploymentAgentPageContext(data),
};
import { pageMeta } from "~/utils/pageTitle";
+import { TextLink } from "~/components/primitives/TextLink";
export const meta = pageMeta(({ params }) => [
params.deploymentParam ?? "Deployment",
@@ -328,12 +329,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 6bdba930f15..680d1f7ea61 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
@@ -81,6 +81,7 @@ export const handle: Handle = {
agentPageContext: () => deploymentsAgentPageContext(),
};
import { pageMeta } from "~/utils/pageTitle";
+import { TextLink } from "~/components/primitives/TextLink";
export const meta = pageMeta("Deployments");
@@ -369,14 +370,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() {
+ {/*
+ Which theme each end of the OS setting resolves to — only meaningful on
+ `system`, so the two rows slide and fade open when it's picked. Animating the
+ grid row track rather than a height keeps the rows at their natural size, and
+ the `visibility` transition holds them on screen while they collapse but
+ takes them out of the tab order once shut.
+ */}
+
+ {/* Clips the rows as the track shrinks, and lets them shrink at all: a grid
+ item only drops below its content height once its overflow isn't visible.
+ Left unpositioned on purpose, so the theme selects' popovers — absolute,
+ against a containing block further up — still escape it when open. */}
+
+
+
+
+
+
+ Choose a theme for the light system setting
+
+
+
+ saveSystemTheme("light", value)}
+ />
+
+
+
+
+
+
+
+
+ Choose the theme for the dark system setting.
+
+
+
+
+
+
+ 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..7c3f6d63a83 100644
--- a/apps/webapp/app/services/dashboardPreferences.server.ts
+++ b/apps/webapp/app/services/dashboardPreferences.server.ts
@@ -17,7 +17,11 @@ export type {
import { type SideMenuSectionId } from "~/components/navigation/sideMenuTypes";
export type { SideMenuSectionId };
-import { type ThemePreference } from "~/utils/themePreference";
+import {
+ type SystemDarkTheme,
+ type SystemLightTheme,
+ type ThemePreference,
+} from "~/utils/themePreference";
export { normalizeThemePreference, type ThemePreference } from "~/utils/themePreference";
export function getDashboardPreferences(data?: any | null): DashboardPreferences {
@@ -178,6 +182,104 @@ 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}
+ `;
+}
+
+/**
+ * Which theme `system` resolves to at one end of the OS setting. `end` names the
+ * key, so both ends share this one narrow jsonb_set write.
+ */
+export async function updateSystemThemePreference({
+ user,
+ end,
+ theme,
+}: {
+ user: UserFromSession;
+ end: "systemLightTheme" | "systemDarkTheme";
+ theme: SystemLightTheme | SystemDarkTheme;
+}) {
+ if (user.isImpersonating) {
+ return;
+ }
+
+ if (user.dashboardPreferences[end] === theme) {
+ return;
+ }
+
+ // Narrow jsonb_set write: see updateThemePreference. The key is a checked
+ // union, never caller-supplied text.
+ const key = end === "systemLightTheme" ? "{systemLightTheme}" : "{systemDarkTheme}";
+ return prisma.$executeRaw`
+ UPDATE "User"
+ SET "dashboardPreferences" = jsonb_set(
+ COALESCE(
+ "dashboardPreferences",
+ '{"version":"1","projects":{}}'::jsonb
+ ),
+ ${key}::text[],
+ to_jsonb(${theme}::text)
+ )
+ 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 e9955747a80..ff7c403ad49 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);
}
@@ -422,15 +435,17 @@
/* dark: follows the app theme (data-theme on ), not the OS preference.
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"] *));
+@custom-variant dark (&:where([data-theme="dark"], [data-theme="classic"], [data-theme="black"], [data-theme="dark"] *, [data-theme="classic"] *, [data-theme="black"] *));
-/* 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. */
-@custom-variant light (&:where([data-theme="light"], [data-theme="light"] *));
+@custom-variant light (&:where([data-theme="light"], [data-theme="white"], [data-theme="light"] *, [data-theme="white"] *));
@utility focus-custom {
&:focus-visible {
@@ -572,7 +587,7 @@
:root {
color-scheme: dark;
}
- [data-theme="light"] {
+ :is([data-theme="light"], [data-theme="white"]) {
color-scheme: light;
}
@@ -586,7 +601,7 @@
}
/* Classic and Dark share these HSL chart vars - they were the app-wide dark values. */
- :is([data-theme="dark"], [data-theme="classic"]) {
+ :is([data-theme="dark"], [data-theme="classic"], [data-theme="black"]) {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
@@ -596,7 +611,7 @@
/* System preference unified chart set (dark+light): Classic's hues, with
chart-2/3 darkened to clear 3:1 on white. Classic keeps the set above. */
- :is([data-theme="dark"], [data-theme="light"]) {
+ :is([data-theme="dark"], [data-theme="light"], [data-theme="black"], [data-theme="white"]) {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 40%;
--chart-3: 30 80% 47%;
@@ -740,7 +755,9 @@
Light theme. Overrides the themable variables only; raw palettes stay put.
Code/editor values come from the trigger.light VS Code theme.
*/
-[data-theme="light"] {
+/* The Light theme's token set, shared with White (which only pins its
+ backgrounds, further down) */
+:is([data-theme="light"], [data-theme="white"]) {
--color-secondary: #ffffff;
--color-input-bg: #ffffff;
@@ -778,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 */
@@ -866,40 +884,84 @@
--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.
+*/
+:is([data-theme="light"], [data-theme="white"]):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 {
+:is([data-theme="light"], [data-theme="white"]) .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;
}
/* Neutral timeline points: invert to a light dot with a gray ring */
-[data-theme="light"] .timeline-point.bg-surface-control-active {
+:is([data-theme="light"], [data-theme="white"]) .timeline-point.bg-surface-control-active {
border-color: var(--color-surface-control-active);
background-color: var(--color-background-bright);
}
/* Run timeline bars: no fade gradient on light */
-[data-theme="light"] .timeline-span {
+:is([data-theme="light"], [data-theme="white"]) .timeline-span {
background-image: none;
}
/* On saturated bars the duration label keeps the dark-theme treatment,
but only when the bar is wide enough to contain the label — on narrow
bars the sticky label overflows onto the page background, where the
default dark-on-light text is correct. */
-[data-theme="light"] .timeline-span.bg-success,
-[data-theme="light"] .timeline-span.bg-error,
-[data-theme="light"] .timeline-span.bg-blue-500 {
+:is([data-theme="light"], [data-theme="white"]) .timeline-span.bg-success,
+:is([data-theme="light"], [data-theme="white"]) .timeline-span.bg-error,
+:is([data-theme="light"], [data-theme="white"]) .timeline-span.bg-blue-500 {
container-type: inline-size;
}
@container (min-width: 3.5rem) {
- [data-theme="light"] .timeline-span.bg-success .text-shadow-custom,
- [data-theme="light"] .timeline-span.bg-error .text-shadow-custom,
- [data-theme="light"] .timeline-span.bg-blue-500 .text-shadow-custom {
+ :is([data-theme="light"], [data-theme="white"]) .timeline-span.bg-success .text-shadow-custom,
+ :is([data-theme="light"], [data-theme="white"]) .timeline-span.bg-error .text-shadow-custom,
+ :is([data-theme="light"], [data-theme="white"]) .timeline-span.bg-blue-500 .text-shadow-custom {
color: #ffffff;
}
}
@@ -907,7 +969,7 @@
/* The table row-hover menu wraps the always-visible "Suggest a region" button
in a ring container; on light that ring doubles up with the button's own
border, so drop it here. */
-[data-theme="light"] .suggest-region-cell > div > div {
+:is([data-theme="light"], [data-theme="white"]) .suggest-region-cell > div > div {
box-shadow: none;
}
@@ -920,7 +982,7 @@
These blocks must stay below the [data-theme="light"] theme block so they
win the cascade.
*/
-[data-theme="dark"] {
+:is([data-theme="dark"], [data-theme="black"]) {
/* Text brightens toward white */
--color-text-bright: color-mix(in srgb, var(--color-charcoal-200), #fff calc(var(--theme-contrast, 0) * 100%));
--color-text-dimmed: color-mix(in srgb, var(--color-charcoal-400), #fff calc(var(--theme-contrast, 0) * 60%));
@@ -945,7 +1007,7 @@
--color-border-brightest: color-mix(in srgb, var(--color-charcoal-500), #fff calc(var(--theme-contrast, 0) * 30%));
}
-[data-theme="light"] {
+:is([data-theme="light"], [data-theme="white"]) {
/* Text deepens toward black */
--color-text-bright: color-mix(in srgb, #1a1b1f, #000 calc(var(--theme-contrast, 0) * 100%));
--color-text-dimmed: color-mix(in srgb, var(--color-charcoal-500), #000 calc(var(--theme-contrast, 0) * 85%));
@@ -969,6 +1031,38 @@
--color-border-brightest: color-mix(in srgb, #9ba1ad, #000 calc(var(--theme-contrast, 0) * 46%));
}
+/*
+ Black and White. Each takes its whole token set from Dark or Light above -
+ text, borders, controls, accents, charts, the contrast overlay - and only pins
+ the surfaces flat, so sections are separated by grid lines rather than by
+ layered fills.
+
+ These must stay below the contrast blocks so the pinned surfaces win: the
+ contrast control still moves text, grid lines, borders and controls, but the
+ backgrounds hold at black or white at every contrast level. Deliberately
+ neutral greys, not charcoal, which carries a blue cast.
+*/
+[data-theme="black"] {
+ /* Page, side menu, cards and popovers all sit flat on black */
+ --color-background-deep: #000000;
+ --color-background-dimmed: #000000;
+ --color-background-bright: #000000;
+ /* Interaction feedback still needs somewhere to go */
+ --color-background-hover: #171717;
+ --color-background-raised: #1f1f1f;
+ /* Inputs read as part of the page; their border does the work */
+ --color-input-bg: #0a0a0a;
+}
+
+[data-theme="white"] {
+ --color-background-deep: #ffffff;
+ --color-background-dimmed: #ffffff;
+ --color-background-bright: #ffffff;
+ --color-background-hover: #f5f5f5;
+ --color-background-raised: #ededed;
+ --color-input-bg: #ffffff;
+}
+
form:has(.unlock-hint-staging-env:hover) [data-unlock-target="staging-env"],
form:has(.unlock-hint-pull-prod:hover) [data-unlock-target="pull-prod"],
form:has(.unlock-hint-pull-stg:hover) [data-unlock-target="pull-stg"],
diff --git a/apps/webapp/app/utils/dashboardPreferences.ts b/apps/webapp/app/utils/dashboardPreferences.ts
index ea0c6d8bfe2..499136f6f16 100644
--- a/apps/webapp/app/utils/dashboardPreferences.ts
+++ b/apps/webapp/app/utils/dashboardPreferences.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { ThemePreference } from "~/utils/themePreference";
+import { SystemDarkTheme, SystemLightTheme, ThemePreference } from "~/utils/themePreference";
/* Schema and pure parsing for the User.dashboardPreferences JSON column.
Kept out of the .server module so tests can exercise the schema without
@@ -52,6 +52,13 @@ 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),
+ /** Which theme `system` resolves to at each end of the OS setting. */
+ systemLightTheme: SystemLightTheme.optional().catch(undefined),
+ systemDarkTheme: SystemDarkTheme.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..4d2a7ba45b5 100644
--- a/apps/webapp/app/utils/themePreference.ts
+++ b/apps/webapp/app/utils/themePreference.ts
@@ -2,9 +2,26 @@ import { z } from "zod";
// Shared between server (dashboard preferences) and client (theme UI, system
// theme sync) - must stay free of server-only imports.
-export const ThemePreference = z.enum(["classic", "system", "dark", "light"]);
+export const ThemePreference = z.enum(["classic", "system", "dark", "light", "black", "white"]);
export type ThemePreference = z.infer;
+/* Which theme `system` resolves to at each end of the OS setting. Both ends have
+ a flat counterpart (White, Black), so the user picks per end. */
+export const SystemLightTheme = z.enum(["light", "white"]);
+export type SystemLightTheme = z.infer;
+export const SystemDarkTheme = z.enum(["dark", "black"]);
+export type SystemDarkTheme = z.infer;
+
+export function normalizeSystemLightTheme(value: unknown): SystemLightTheme {
+ const result = SystemLightTheme.safeParse(value);
+ return result.success ? result.data : "light";
+}
+
+export function normalizeSystemDarkTheme(value: unknown): SystemDarkTheme {
+ const result = SystemDarkTheme.safeParse(value);
+ return result.success ? result.data : "dark";
+}
+
/** Coerce any stored/legacy value into a valid preference. Missing or unknown
* values fall back to `dark` - the new dark theme is the default (pinned, not
* system-resolved, so nobody gets surprised by light mode). */
@@ -16,6 +33,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 {