{group.label}
diff --git a/apps/webapp/app/routes/storybook.usage/route.tsx b/apps/webapp/app/routes/storybook.usage/route.tsx
new file mode 100644
index 00000000000..063449ad3b0
--- /dev/null
+++ b/apps/webapp/app/routes/storybook.usage/route.tsx
@@ -0,0 +1,47 @@
+import { UsageSparkline } from "~/components/primitives/UsageSparkline";
+import { Story, StoryGrid, StoryPage, StorySection } from "../storybook/StoryKit";
+
+/* 24 hourly buckets with a believable shape: quiet overnight, busy afternoon. */
+const DAY = [0, 0, 1, 0, 2, 4, 9, 14, 22, 31, 28, 35, 41, 38, 52, 61, 48, 39, 27, 18, 12, 7, 3, 1];
+const SPARSE = [0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 1, 0, 0, 0, 0];
+
+export default function Story_() {
+ return (
+
+
+
+
+
+
+
+ v * 1000)}
+ color="var(--color-success)"
+ unitLabel={{ singular: "token", plural: "tokens" }}
+ totalClassName="text-success"
+ />
+
+
+
+
+
+ `peak ${t}`} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/storybook/StoryKit.tsx b/apps/webapp/app/routes/storybook/StoryKit.tsx
new file mode 100644
index 00000000000..8aec8c1f354
--- /dev/null
+++ b/apps/webapp/app/routes/storybook/StoryKit.tsx
@@ -0,0 +1,139 @@
+import { type CSSProperties, type ReactNode } from "react";
+import { CopyableText } from "~/components/primitives/CopyableText";
+import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { cn } from "~/utils/cn";
+
+/* Shared scaffolding for storybook pages: one long scrolling page per component
+ (or component group), split into titled sections whose samples sit in a
+ responsive grid of labelled cells. */
+
+/**
+ * The component's own file name, copyable on hover, so a story can be traced
+ * back to source without hunting. Pass every file a page covers.
+ */
+export function ComponentNames({ names }: { names: string[] }) {
+ return (
+
+ {names.map((name) => (
+
+ ))}
+
+ );
+}
+
+export function StoryPage({
+ title,
+ componentNames,
+ description,
+ children,
+ className,
+}: {
+ title: string;
+ /** File names of the components shown, e.g. ["Buttons.tsx"]. */
+ componentNames?: string[];
+ description?: string;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+
{title}
+ {componentNames && componentNames.length > 0 &&
}
+ {description &&
{description}}
+
+ {children}
+
+ );
+}
+
+export function StorySection({
+ title,
+ componentName,
+ description,
+ children,
+ className,
+}: {
+ title: string;
+ /** Shown beside the heading when a page covers several component files. */
+ componentName?: string;
+ description?: string;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+
+ {title}
+ {componentName && (
+
+ )}
+
+ {description &&
{description}}
+
+ {children}
+
+ );
+}
+
+/** Sub-heading inside a section, for grouping variants of one component. */
+export function StorySubSection({
+ title,
+ children,
+ className,
+}: {
+ title: string;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
+
+/** Responsive auto-fill grid; tune the cell floor with `min`. */
+export function StoryGrid({
+ children,
+ min = "12rem",
+ className,
+}: {
+ children: ReactNode;
+ min?: string;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** One labelled sample. */
+export function Story({
+ label,
+ children,
+ className,
+ contentClassName,
+}: {
+ label: string;
+ children: ReactNode;
+ className?: string;
+ contentClassName?: string;
+}) {
+ return (
+
+
+ {label}
+
+
{children}
+
+ );
+}
diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx
index e423318c493..2c76eaab150 100644
--- a/apps/webapp/app/routes/storybook/route.tsx
+++ b/apps/webapp/app/routes/storybook/route.tsx
@@ -1,265 +1,326 @@
import { NavLink, Outlet } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { Fragment } from "react";
-import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
+import { useEffect, useRef, useState } from "react";
+import { redirect, typedjson, useTypedLoaderData, useTypedRouteLoaderData } from "remix-typedjson";
import { AppContainer } from "~/components/layout/AppLayout";
+import { Header2 } from "~/components/primitives/Headers";
+import SegmentedControl from "~/components/primitives/SegmentedControl";
+import { ShortcutKey } from "~/components/primitives/ShortcutKey";
+import { Switch } from "~/components/primitives/Switch";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
+import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
+import { applyThemePreference } from "~/hooks/useSystemThemeSync";
+import { type loader as rootLoader } from "~/root";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
+import { type ThemePreference } from "~/utils/themePreference";
-const stories: Story[] = [
- {
- name: "Animated panel",
- slug: "animated-panel",
- },
- {
- name: "Avatar",
- slug: "avatar",
- },
- {
- name: "Badges",
- slug: "badges",
- },
- {
- name: "Buttons",
- slug: "buttons",
- },
- {
- name: "Callouts",
- slug: "callout",
- },
- {
- name: "Charts",
- slug: "charts",
- },
- {
- name: "Checkboxes",
- slug: "checkboxes",
- },
- {
- name: "Clipboard field",
- slug: "clipboard-field",
- },
- {
- name: "Code block",
- slug: "code-block",
- },
- {
- name: "Detail cell",
- slug: "detail-cell",
- },
- {
- name: "Dialog",
- slug: "dialog",
- },
- {
- name: "Environment label",
- slug: "environment-label",
- },
- {
- name: "Free plan usage",
- slug: "free-plan-usage",
- },
- {
- name: "Icons",
- slug: "icons",
- },
- {
- name: "Info panel",
- slug: "info-panel",
- },
- {
- name: "Inline code",
- slug: "inline-code",
- },
- {
- name: "Layout",
- slug: "layout",
- },
- {
- name: "Loading bar divider",
- slug: "loading-bar-divider",
- },
- {
- name: "Page header",
- slug: "page-header",
- },
- {
- name: "Pricing callout",
- slug: "pricing-callout",
- },
- {
- name: "Radio group",
- slug: "radio-group",
- },
- {
- name: "Resizable",
- slug: "resizable",
- },
- {
- name: "Run & Span timeline",
- slug: "run-and-span-timeline",
- },
- {
- name: "Segemented control",
- slug: "segmented-control",
- },
- {
- name: "Shortcuts",
- slug: "shortcuts",
- },
- {
- name: "Spinners",
- slug: "spinner",
- },
- {
- name: "Streamdown",
- slug: "streamdown",
- },
- {
- name: "Switch",
- slug: "switch",
- },
- {
- name: "Tables",
- slug: "table",
- },
- {
- name: "Tabs",
- slug: "tabs",
- },
- {
- name: "Timeline",
- slug: "timeline",
- },
- {
- name: "Toast",
- slug: "toast",
- },
- {
- name: "Tooltip",
- slug: "tooltip",
- },
- {
- name: "Tree view",
- slug: "tree-view",
- },
- {
- name: "TSQL Editor",
- slug: "tsql-editor",
- },
- {
- name: "Typography",
- slug: "typography",
- },
- {
- name: "Unordered list",
- slug: "unordered-list",
- },
- {
- name: "Usage",
- slug: "usage",
- },
- {
- sectionTitle: "Trigger Agent",
- name: "Chat UI",
- slug: "agent-ui",
- },
- {
- name: "View blocks",
- slug: "agent-view-blocks",
- },
- {
- name: "Report view",
- slug: "agent-report",
- },
- {
- name: "Investigation card",
- slug: "agent-investigation",
- },
- {
- name: "Watch card",
- slug: "agent-watch",
- },
- {
- name: "Icons & Buttons",
- slug: "ai-agent",
- },
- // Forms section
- {
- sectionTitle: "Forms",
- name: "Date fields",
- slug: "date-fields",
- },
- {
- name: "Input fields",
- slug: "input-fields",
- },
- {
- name: "Search fields",
- slug: "search-fields",
- },
- {
- name: "Simple form",
- slug: "simple-form",
+type Story = {
+ name: string;
+ slug: string;
+};
+
+type StorySection = {
+ title: string;
+ items: Story[];
+};
+
+const sections: StorySection[] = [
+ {
+ title: "Foundations",
+ items: [
+ { name: "Colors", slug: "colors" },
+ { name: "Typography", slug: "typography" },
+ { name: "Icons", slug: "icons" },
+ { name: "Avatars", slug: "avatar" },
+ { name: "Layout", slug: "layout" },
+ { name: "Shortcuts", slug: "shortcuts" },
+ { name: "Unordered list", slug: "unordered-list" },
+ ],
+ },
+ {
+ title: "Actions",
+ items: [
+ { name: "Buttons", slug: "buttons" },
+ { name: "Segmented control", slug: "segmented-control" },
+ { name: "Pagination", slug: "pagination" },
+ { name: "Copy & clipboard", slug: "copy" },
+ { name: "Clipboard field", slug: "clipboard-field" },
+ ],
+ },
+ {
+ title: "Forms",
+ items: [
+ { name: "Input fields", slug: "input-fields" },
+ { name: "Search fields", slug: "search-fields" },
+ { name: "Textarea", slug: "textarea" },
+ { name: "Checkboxes", slug: "checkboxes" },
+ { name: "Radio group", slug: "radio-group" },
+ { name: "Switch", slug: "switch" },
+ { name: "Slider", slug: "slider" },
+ { name: "Stepper", slug: "stepper" },
+ { name: "Date fields", slug: "date-fields" },
+ { name: "Simple form", slug: "simple-form" },
+ ],
+ },
+ {
+ title: "Menus & overlays",
+ items: [
+ { name: "Select", slug: "select" },
+ { name: "Popover", slug: "popover" },
+ { name: "Filter", slug: "filter" },
+ { name: "Dialog", slug: "dialog" },
+ { name: "Sheet", slug: "sheet" },
+ { name: "Tooltip", slug: "tooltip" },
+ ],
+ },
+ {
+ title: "Feedback",
+ items: [
+ { name: "Badges", slug: "badges" },
+ { name: "Callouts", slug: "callout" },
+ { name: "Pricing callout", slug: "pricing-callout" },
+ { name: "Info panel", slug: "info-panel" },
+ { name: "Toast", slug: "toast" },
+ { name: "Spinners", slug: "spinner" },
+ { name: "Loading bar divider", slug: "loading-bar-divider" },
+ { name: "Free plan usage", slug: "free-plan-usage" },
+ { name: "Indicators", slug: "indicators" },
+ ],
+ },
+ {
+ title: "Navigation",
+ items: [
+ { name: "Tabs", slug: "tabs" },
+ { name: "Page header", slug: "page-header" },
+ { name: "Tree view", slug: "tree-view" },
+ { name: "Resizable", slug: "resizable" },
+ { name: "Animated panel", slug: "animated-panel" },
+ { name: "Accordion", slug: "accordion" },
+ ],
+ },
+ {
+ title: "Data display",
+ items: [
+ { name: "Tables", slug: "table" },
+ { name: "Cells & key-value", slug: "detail-cell" },
+ { name: "Charts", slug: "charts" },
+ { name: "Usage sparkline", slug: "usage" },
+ { name: "Timeline", slug: "timeline" },
+ { name: "Run & Span timeline", slug: "run-and-span-timeline" },
+ { name: "Code block", slug: "code-block" },
+ { name: "Inline code", slug: "inline-code" },
+ { name: "Streamdown", slug: "streamdown" },
+ { name: "TSQL Editor", slug: "tsql-editor" },
+ ],
+ },
+ {
+ title: "Runs & logs",
+ items: [
+ { name: "Run statuses", slug: "run-statuses" },
+ { name: "Log levels", slug: "log-levels" },
+ { name: "Dates & timers", slug: "dates-timers" },
+ { name: "Environment label", slug: "environment-label" },
+ ],
+ },
+ {
+ title: "Settings",
+ items: [{ name: "Settings rows", slug: "settings-rows" }],
+ },
+ {
+ title: "Trigger Agent",
+ items: [
+ { name: "Chat UI", slug: "agent-ui" },
+ { name: "View blocks", slug: "agent-view-blocks" },
+ { name: "Report view", slug: "agent-report" },
+ { name: "Investigation card", slug: "agent-investigation" },
+ { name: "Watch card", slug: "agent-watch" },
+ { name: "Icons & Buttons", slug: "ai-agent" },
+ ],
},
+];
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUser(request);
+
+ if (!user.admin) {
+ throw redirect("/");
+ }
+
+ return typedjson({
+ sections,
+ });
+};
+
+/* The storybook's theme is its own, so components can be checked in every theme
+ without touching the account preference. Default is System.
+
+ Each segment gets mod+1..5. The browser binds those to tab switching, hence
+ preventDefault; the shortcuts only exist while this layout is mounted, so
+ they're scoped to the storybook. */
+const THEME_OPTIONS: { label: string; value: ThemePreference; shortcut: ShortcutDefinition }[] = [
{
- name: "Stepper",
- slug: "stepper",
+ label: "System",
+ value: "system",
+ shortcut: { key: "1", modifiers: ["mod"], preventDefault: true },
},
{
- name: "Textarea",
- slug: "textarea",
+ label: "Light",
+ value: "light",
+ shortcut: { key: "2", modifiers: ["mod"], preventDefault: true },
},
- // Menus section
{
- sectionTitle: "Menus",
- name: "Filter",
- slug: "filter",
+ label: "Dark",
+ value: "dark",
+ shortcut: { key: "3", modifiers: ["mod"], preventDefault: true },
},
{
- name: "Popover",
- slug: "popover",
+ label: "White",
+ value: "white",
+ shortcut: { key: "4", modifiers: ["mod"], preventDefault: true },
},
{
- name: "Select",
- slug: "select",
+ label: "Black",
+ value: "black",
+ shortcut: { key: "5", modifiers: ["mod"], preventDefault: true },
},
];
-export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const user = await requireUser(request);
+/** Hover hint carrying the theme name and its key, on the standard 500ms delay. */
+const TOOLTIP_DELAY_MS = 500;
- if (!user.admin) {
- throw redirect("/");
- }
+function ThemeSegmentLabel({ label, shortcut }: { label: string; shortcut: ShortcutDefinition }) {
+ return (
+
{label}}
+ content={
+
+ {label}
+
+
+ }
+ side="bottom"
+ delayDuration={TOOLTIP_DELAY_MS}
+ disableHoverableContent
+ asChild
+ />
+ );
+}
- return typedjson({
- stories,
- });
-};
+/** Binds one theme's shortcut. Separate component so each gets its own hook. */
+function ThemeShortcut({
+ shortcut,
+ onTrigger,
+}: {
+ shortcut: ShortcutDefinition;
+ onTrigger: () => void;
+}) {
+ useShortcutKeys({ shortcut, action: onTrigger });
+ return null;
+}
+
+/* The accessibility preference is a plain attribute on , so the storybook
+ can flip it locally the same way it does the theme, and hand it back on the
+ way out. The stored key is still `iconContrast`. */
+function useStorybookIconContrast() {
+ const rootData = useTypedRouteLoaderData("root");
+ const [iconContrast, setIconContrast] = useState(false);
+
+ const savedIconContrast = useRef(rootData?.iconContrast);
+ savedIconContrast.current = rootData?.iconContrast;
+
+ useEffect(() => {
+ document.documentElement.setAttribute("data-icon-contrast", iconContrast ? "true" : "false");
+ }, [iconContrast]);
+
+ useEffect(() => {
+ return () => {
+ document.documentElement.setAttribute(
+ "data-icon-contrast",
+ savedIconContrast.current ? "true" : "false"
+ );
+ };
+ }, []);
+
+ return [iconContrast, setIconContrast] as const;
+}
+
+function useStorybookTheme() {
+ const rootData = useTypedRouteLoaderData("root");
+ const [theme, setTheme] = useState("system");
+
+ // Refs so the unmount cleanup restores whatever the account preference is by
+ // then, without re-running the restore on data revalidation.
+ const savedPreference = useRef(rootData?.themePreference);
+ const savedSystemThemes = useRef(rootData?.systemThemes);
+ savedPreference.current = rootData?.themePreference;
+ savedSystemThemes.current = rootData?.systemThemes;
+
+ const systemThemes = rootData?.systemThemes;
+ useEffect(() => {
+ applyThemePreference(theme, systemThemes);
+ }, [theme, systemThemes]);
+
+ // Leaving the storybook hands the theme back to the account preference.
+ useEffect(() => {
+ return () => {
+ if (savedPreference.current) {
+ applyThemePreference(savedPreference.current, savedSystemThemes.current);
+ }
+ };
+ }, []);
+
+ return [theme, setTheme] as const;
+}
export default function App() {
- const { stories } = useTypedLoaderData();
+ const { sections } = useTypedLoaderData();
+ const [theme, setTheme] = useStorybookTheme();
+ const [iconContrast, setIconContrast] = useStorybookIconContrast();
return (
+ {THEME_OPTIONS.map((option) => (
+ setTheme(option.value)}
+ />
+ ))}
-
-
-
+
+
+
+
Storybook
+
+
+ ({
+ value: option.value,
+ label: ,
+ }))}
+ variant="secondary/small"
+ onChange={(value) => setTheme(value as ThemePreference)}
+ />
+
+
+
+
+
);
}
-type Story = {
- name: string;
- slug: string;
- sectionTitle?: string;
-};
-
-function SideMenu({ stories }: { stories: Story[] }) {
+function SideMenu({ sections }: { sections: StorySection[] }) {
return (
-
- {stories.map((story) => {
- return (
-
- {story.sectionTitle && (
-
- {story.sectionTitle}
-
- )}
-
+
+ {sections.map((section) => (
+
+
+ {section.title}
+
+ {section.items.map((story) => (
+
{({ isActive, isPending }) => (
)}
-
- );
- })}
+ ))}
+
+ ))}
diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts
index 6916f1d6c4e..f94bbc13d02 100644
--- a/apps/webapp/app/services/dashboardPreferences.server.ts
+++ b/apps/webapp/app/services/dashboardPreferences.server.ts
@@ -13,7 +13,11 @@ export type { DashboardPreferences, FavoritePage } from "~/utils/dashboardPrefer
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 { type ThemePreference } from "~/utils/themePreference";
export function getDashboardPreferences(data?: any | null): DashboardPreferences {
@@ -174,6 +178,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/services/profileUpdateRateLimiter.server.ts b/apps/webapp/app/services/profileUpdateRateLimiter.server.ts
new file mode 100644
index 00000000000..7108e52fab3
--- /dev/null
+++ b/apps/webapp/app/services/profileUpdateRateLimiter.server.ts
@@ -0,0 +1,34 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { type RedisWithClusterOptions } from "~/redis.server";
+import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
+import { singleton } from "~/utils/singleton";
+
+// Each row of the profile page now writes to the User row on its own — a switch
+// is one click, one write — so there's no submit button pacing the writes any
+// more. The client debounces and keeps one write in flight, but that's a
+// courtesy a scripted POST skips, and `/account` sits outside `/api/*` so the
+// global apiRateLimiter doesn't cover it. Hence a per-user cap here.
+//
+// Exported so the policy is asserted in tests rather than re-encoded.
+const PROFILE_UPDATE_RATE_LIMIT_ATTEMPTS = 20;
+const PROFILE_UPDATE_RATE_LIMIT_WINDOW = "1 m" as const;
+
+/**
+ * Build the profile-update per-user rate limiter. Production uses the
+ * env-derived rate-limit Redis; tests inject a container Redis.
+ */
+function createProfileUpdateRateLimiter(redisOptions?: RedisWithClusterOptions): RateLimiter {
+ return new RateLimiter({
+ ...(redisOptions ? { redisClient: createRedisRateLimitClient(redisOptions) } : {}),
+ keyPrefix: "account.profile-update",
+ limiter: Ratelimit.slidingWindow(
+ PROFILE_UPDATE_RATE_LIMIT_ATTEMPTS,
+ PROFILE_UPDATE_RATE_LIMIT_WINDOW
+ ),
+ logFailure: true,
+ });
+}
+
+export const profileUpdateRateLimiter = singleton("profileUpdateRateLimiter", () =>
+ createProfileUpdateRateLimiter()
+);
diff --git a/apps/webapp/app/services/ssoManagedIdentity.server.ts b/apps/webapp/app/services/ssoManagedIdentity.server.ts
new file mode 100644
index 00000000000..e7166a13624
--- /dev/null
+++ b/apps/webapp/app/services/ssoManagedIdentity.server.ts
@@ -0,0 +1,60 @@
+import { prisma } from "~/db.server";
+import { logger } from "~/services/logger.server";
+import { ssoController } from "~/services/sso.server";
+
+/**
+ * Whether a user's identity is owned by an identity provider rather than by the
+ * user — true when any organization they belong to has SSO set up with at least
+ * one active connection.
+ *
+ * "Set up with an active connection" is the same thing the SSO settings page
+ * calls active (`hasIdpOrg` plus a live connection): a configured-but-dead
+ * connection can't sign anyone in, so on its own it doesn't mean the IdP owns
+ * the account. This is deliberately NOT the Enterprise plan flag — an org can be
+ * on Enterprise without ever configuring SSO, and that org's members own their
+ * own email addresses.
+ *
+ * Any org counts, not just the "current" one: this page has no org in scope, and
+ * a user who is in one SSO org and one personal org still authenticates through
+ * that IdP.
+ *
+ * Fails CLOSED. If the SSO plugin can't answer we treat the account as
+ * IdP-managed, because the alternative is letting an outage open up a write we
+ * can't verify. On OSS (no plugin installed) this short-circuits to `false`
+ * before any query runs, so self-hosters pay nothing and never see the branch.
+ */
+export async function isSsoManagedUser(userId: string): Promise
{
+ if (!(await ssoController.isUsingPlugin())) {
+ return false;
+ }
+
+ const memberships = await prisma.orgMember.findMany({
+ where: { userId, organization: { deletedAt: null } },
+ select: { organizationId: true },
+ });
+
+ if (memberships.length === 0) {
+ return false;
+ }
+
+ const statuses = await Promise.all(
+ memberships.map((membership) => ssoController.getStatus(membership.organizationId))
+ );
+
+ for (const [index, status] of statuses.entries()) {
+ if (status.isErr()) {
+ logger.warn("SSO status lookup failed; treating the account as IdP-managed", {
+ userId,
+ organizationId: memberships[index].organizationId,
+ reason: status.error,
+ });
+ return true;
+ }
+
+ if (status.value.hasIdpOrg && status.value.connections.some((c) => c.state === "active")) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css
index 6a303778fd2..bc366a7d346 100644
--- a/apps/webapp/app/tailwind.css
+++ b/apps/webapp/app/tailwind.css
@@ -141,10 +141,26 @@
*/
@theme {
/* Text colors */
- --color-primary: var(--color-apple-500);
+ /* The brand accent. Points at the link accent rather than carrying its own
+ value: the app had one indigo doing the work of a primary color and an
+ apple green sitting unused beside it, so this is the same hue by
+ definition and can't drift from it. */
+ --color-primary: var(--color-text-link);
--color-secondary: var(--color-charcoal-650);
--color-tertiary: var(--color-charcoal-700);
--color-text-link: var(--color-lavender-400);
+ /* Link hover: one stop up the ramp. Sized to be unmistakable - a step the
+ size of TextLink's own indigo-500 -> indigo-400 was too subtle to read as a
+ hover at all. This lands just under the largest hover step the app already
+ uses (text-dimmed -> text-bright). */
+ --color-text-link-hover: var(--color-lavender-300);
+ /* The same brand purple as a *fill* under white content - the primary button
+ and the switch's checked track, which must agree with each other. One value
+ for every theme: white content needs 4.5:1, which pins it dark, while
+ --color-text-link above has to travel the other way to stay legible as text
+ on a near-black page. So the fill and the link color match on the light
+ themes and can't on the dark ones. */
+ --color-accent-fill: var(--color-lavender-600);
--color-text-faint: var(--color-charcoal-500);
--color-text-dimmed: var(--color-charcoal-400);
--color-text-bright: var(--color-charcoal-200);
@@ -175,13 +191,22 @@
--color-success: var(--color-mint-500);
--color-pending: var(--color-blue-500);
--color-warning: var(--color-amber-500);
+
+ /* Log level accent for TRACE. The chip was tokenised without this ever being
+ declared, so bg-log-trace and border-log-trace generated no CSS at all and
+ the chip rendered bare. purple-500 is what the raw classes used. */
+ --color-log-trace: var(--color-purple-500);
--color-error: var(--color-rose-600);
/* Environment colors */
--color-dev: var(--color-pink-500);
--color-staging: var(--color-orange-400);
--color-prod: var(--color-mint-500);
- --color-preview: var(--color-yellow-400);
+ /* Blue in every theme, including under Stronger colors: yellow needed a
+ different value per mode to stay legible (yellow-400 on dark, yellow-700 on
+ white, which reads brown), and the two ends stopped looking like one
+ environment. */
+ --color-preview: var(--color-blue-500);
/* Icon colors */
--color-tasks: var(--color-blue-500);
@@ -213,7 +238,8 @@
--color-sessions: var(--color-pink-500);
--color-playgrounds: var(--color-fuchsia-500);
--color-models: var(--color-violet-500);
- --color-previewBranches: var(--color-yellow-500);
+ /* Branch labels sit inside a preview environment, so they track its color. */
+ --color-previewBranches: var(--color-blue-500);
}
/* Callout variant accents (see components/primitives/Callout.tsx) */
@@ -238,28 +264,28 @@
*/
/*
- 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 "Distinguish without color" 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
+ base value 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 the base accents from @theme; the
+ Light theme darkens them for white further down, holding those same 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);
- /* Env set: pink dev and green prod as in Classic, preview moves to blue
- (yellow's unified mid-tone reads muddy brown). Staging stays warm orange
- and is the one per-mode env color - see the light block. */
+ /* Env set: pink dev and green prod as in the base set. Preview and its branch
+ labels are blue in every theme now, so they need no entry here. Staging
+ stays warm orange and is the one per-mode env color - see the light block. */
--color-prod: var(--color-mint-600);
- --color-preview: var(--color-blue-500);
/* Icons */
--color-schedules: var(--color-yellow-700);
- /* Matches the preview env color */
- --color-previewBranches: var(--color-blue-500);
--color-metrics: var(--color-green-600);
--color-regions: var(--color-green-600);
--color-aiMetrics: var(--color-green-600);
@@ -268,7 +294,7 @@
--color-errors: var(--color-amber-600);
--color-apiKeys: var(--color-amber-600);
- /* Queue charts read blue here; Classic keeps the queues purple */
+ /* Queue charts read blue here; the base set keeps the queues purple */
--color-queues-chart: var(--color-blue-500);
--color-queues-chart-ref: var(--color-charcoal-500);
@@ -277,7 +303,7 @@
--color-callout-pending: var(--color-blue-500);
--color-callout-pricing: var(--color-indigo-500);
- /* Amber run statuses: three distinct steps, Classic's dark-to-light order
+ /* Amber run statuses: three distinct steps, the base dark-to-light order
kept (waiting-for-deploy < pending-version < paused) */
--color-run-waiting-for-deploy: var(--color-amber-700);
--color-run-pending-version: #c76508;
@@ -286,29 +312,52 @@
--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 body-text links - those carrying the
+ marker class from the TextLink component, plus markdown prose links, which
+ can't take a class because streamdown renders the anchors. Decorative
+ underlines (dashed tooltip terms, tab underlines, the always-underlined
+ citation links in agent reports) are untouched either way.
+
+ Links in this set never underline on hover alone; hover shifts their color
+ instead, so the underline means "this preference is on" and nothing else. */
+[data-underline-links="true"] :is(.inline-text-link, .streamdown-container a) {
+ 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);
}
+/* The ring is drawn in currentcolor, which is white on a chip that fills solid -
+ fine against a dark page, where it reads as an edge, but on white it reads as a
+ stray border inset into the fill. Cancelled there, and only for the chips that
+ actually fill: the tinted ones still need the edge it was written for. */
+[data-icon-contrast="true"]:is([data-theme="light"], [data-theme="white"])
+ .contrast-chip-solid {
+ box-shadow: none;
+}
+
/*
Code syntax palette - the trigger-dark highlight theme, shared by the shiki
theme (streamdown) and the prism theme (CodeBlock). Consumed from JS via
@@ -376,7 +425,7 @@
--color-editor-scrollbar-thumb-active: #3c4b62;
}
-/* Queue chart series colors - queues purple in Classic, overridden to blue in
+/* Queue chart series colors - queues purple in the base set, overridden to blue in
the System themes; the grey reference series lightens per mode there.
Consumed from JS via var(), so declared static. */
@theme static {
@@ -428,16 +477,18 @@
@custom-variant md-height (@media (max-height: 600px));
/* 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"] *));
+ Dark and Black are both dark-mode themes, so the variant matches both. */
+@custom-variant dark (&:where([data-theme="dark"], [data-theme="black"], [data-theme="dark"] *, [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 "Distinguish without color" preference in any theme; off means the
+ base 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"] *));
+/* light: the light-mode themes only - for values that are fine on every dark
+ theme but illegible on white. */
+@custom-variant light (&:where([data-theme="light"], [data-theme="white"], [data-theme="light"] *, [data-theme="white"] *));
@utility focus-custom {
&:focus-visible {
@@ -579,7 +630,7 @@
:root {
color-scheme: dark;
}
- [data-theme="light"] {
+ :is([data-theme="light"], [data-theme="white"]) {
color-scheme: light;
}
@@ -592,8 +643,8 @@
--chart-5: 27 87% 67%;
}
- /* Classic and Dark share these HSL chart vars - they were the app-wide dark values. */
- :is([data-theme="dark"], [data-theme="classic"]) {
+ /* The app-wide dark chart set. */
+ :is([data-theme="dark"], [data-theme="black"]) {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
@@ -601,9 +652,8 @@
--chart-5: 340 75% 55%;
}
- /* 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"]) {
+ /* Light keeps the same hues with chart-2/3 darkened to clear 3:1 on white. */
+ :is([data-theme="light"], [data-theme="white"]) {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 40%;
--chart-3: 30 80% 47%;
@@ -611,6 +661,15 @@
--chart-5: 340 75% 55%;
}
+ /* With the accessibility preference on, the dark themes adopt Light's darker
+ chart-2/3 as well. Two reasons: they hold a higher ratio against the plot
+ background, and it makes the series colours identical across both ends of
+ the `system` setting, so a chart doesn't shift hue when the OS flips. */
+ [data-icon-contrast="true"]:is([data-theme="dark"], [data-theme="black"]) {
+ --chart-2: 160 60% 40%;
+ --chart-3: 30 80% 47%;
+ }
+
/* Override react-grid-layout placeholder color (default is red) */
.react-grid-item.react-grid-placeholder {
background: rgb(99 102 241) !important; /* indigo-500 */
@@ -713,8 +772,13 @@
& blockquote {
@apply border-l-2 border-charcoal-600 pl-3 my-2 italic;
}
+ /* Matches TextLink: the link token, brightening on hover, and underlined only
+ when the "Underline links" preference says so - see the rule that opts these
+ anchors into it. `no-underline` is load-bearing: streamdown hard-codes
+ `underline` onto every anchor it renders, and this outspecifies it (a class
+ plus a type selector beats a bare class). */
& a {
- @apply text-text-link hover:underline;
+ @apply text-text-link no-underline transition hover:text-text-link-hover;
}
& strong {
@apply font-semibold text-text-bright;
@@ -747,7 +811,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;
@@ -761,9 +827,13 @@
--primary-foreground: #ffffff;
/* Text */
- --color-primary: var(--color-apple-600);
--color-tertiary: #eef0f3;
--color-text-link: var(--color-lavender-600);
+ /* Hover goes *down* the ramp here, not up: on white, more prominent means
+ darker. Same direction as every other light-mode hover, where
+ text-dimmed -> text-bright also darkens. Two stops rather than one, because
+ 600 and 700 sit close together - one stop is invisible at text sizes. */
+ --color-text-link-hover: var(--color-lavender-800);
--color-text-faint: var(--color-charcoal-400);
--color-text-dimmed: var(--color-charcoal-500);
--color-text-bright: var(--color-charcoal-800);
@@ -792,11 +862,12 @@
--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
+ "Distinguish without color" is on, or the base-derived block below when
+ it's off. Either way dev keeps its base pink-500, which passes on white. */
/* 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 */
+ mid-tone, so dark keeps the base bright orange-400 and light deepens it */
--color-staging: var(--color-orange-600);
/* Grey chart reference series, light enough not to dominate on white */
@@ -880,40 +951,117 @@
--color-editor-scrollbar-thumb-active: #aeb3be;
}
+/*
+ Light themes with "Distinguish without color" off: the base 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 its base 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 base 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);
+
+ /* Icons */
+ --color-schedules: 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 the base purple, one step down for white */
+ --color-queues-chart: var(--color-purple-600);
+ --color-log-trace: 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, the base 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;
+}
+
+/*
+ Neutral run statuses on the light themes, with Stronger colors on. Queued,
+ Delayed, Waiting, Canceled and Expired are pale greys chosen to sit quietly on
+ a white plot, which puts all five between 1.32:1 and 1.66:1 - under the 3:1 a
+ chart series needs.
+
+ charcoal-400 is the first stop on the ramp that clears it (3.36:1); charcoal-300
+ is still 1.98:1. They all land on the same stop, which sounds worse than it is:
+ the five were already within 1.05:1 of each other, so nothing was
+ distinguishable between them to lose. If they ever need to be told apart, that's
+ a separate decision about spreading them down the ramp - it can't be done
+ without pushing four of them darker than the floor requires.
+
+ The dark themes are untouched: their greys already clear 3:1 against a dark plot.
+*/
+/* The TRACE chip is the one level that stays an outline with Stronger colors on
+ the light themes: filled purple with a white label it competes with ERROR two
+ chips along, where a dark-grey rule and label sit quieter while still clearing
+ 4.5:1 (charcoal-600 is 10.71:1 on white). The other four fill solid. */
+[data-icon-contrast="true"]:is([data-theme="light"], [data-theme="white"])
+ .log-level-chip-trace {
+ background-color: transparent;
+ border-color: var(--color-charcoal-600);
+ color: var(--color-charcoal-600);
+}
+
+[data-icon-contrast="true"]:is([data-theme="light"], [data-theme="white"]) {
+ --color-run-pending: var(--color-charcoal-400);
+ --color-run-delayed: var(--color-charcoal-400);
+ --color-run-waiting-to-resume: var(--color-charcoal-400);
+ --color-run-canceled: var(--color-charcoal-400);
+ --color-run-expired: var(--color-charcoal-400);
+}
+
/* 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 base 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;
}
}
@@ -921,72 +1069,163 @@
/* 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;
}
/*
- Interface contrast (System themes only). --theme-contrast is 0..1, set on
- from the dashboard preference. It pulls the whole monochrome scale
- apart: text mixes toward the mode's foreground, backgrounds toward its
- depth, borders step up. Accents are untouched. srgb mixing keeps contrast 0
- byte-identical to the base values, and Classic never reads the variable.
- These blocks must stay below the [data-theme="light"] theme block so they
- win the cascade.
+ Interface contrast. --theme-contrast is 0..1, set on from the dashboard
+ preference. It pulls the monochrome scale apart: text and borders travel
+ toward the light end of the charcoal ramp, backgrounds toward the dark end.
+ Accents are untouched.
+
+ Every token travels BETWEEN TWO CHARCOAL STOPS rather than toward #fff/#000.
+ The ramp is a hand-picked set of cool-leaning greys whose tint peaks in the
+ midtones (B-R runs 4, 6, 11, 18, 17, 14, 10 ... 5, 4), and mixing toward pure
+ white or black flattened that curve - at full contrast the old grid-bright
+ landed on tint +6 where the ramp's own grey at that brightness carries +14.
+ Interpolating stop-to-stop keeps every intermediate value inside the palette.
+
+ Each token moves TWO stops, except the text group, which moves one: text-bright
+ starts one stop from the ramp's light end, and moving the group together keeps
+ the spacing between bright, dimmed and faint identical at both ends of the
+ slider. Two stops also preserves the pairs that are deliberately equal at 0%
+ (surface-control/border-bright, and input-bg/background-hover).
+
+ At contrast 0 every value is its base stop, so this is byte-identical to the
+ palette the Classic theme used to ship. These blocks must stay below the
+ [data-theme="light"] theme block so they win the cascade.
+*/
+:is([data-theme="dark"], [data-theme="black"]) {
+ /* Text travels toward the ramp's light end - one stop, so the group keeps its spacing */
+ --color-text-bright: color-mix(in srgb, var(--color-charcoal-200), var(--color-charcoal-100) calc(var(--theme-contrast, 0) * 100%));
+ --color-text-dimmed: color-mix(in srgb, var(--color-charcoal-400), var(--color-charcoal-300) calc(var(--theme-contrast, 0) * 100%));
+ --color-text-faint: color-mix(in srgb, var(--color-charcoal-500), var(--color-charcoal-400) calc(var(--theme-contrast, 0) * 100%));
+
+ /* Backgrounds travel toward the dark end, keeping their relative order */
+ --color-background-deep: color-mix(in srgb, var(--color-charcoal-900), var(--color-charcoal-1000) calc(var(--theme-contrast, 0) * 100%));
+ --color-background-dimmed: color-mix(in srgb, var(--color-charcoal-850), var(--color-charcoal-950) calc(var(--theme-contrast, 0) * 100%));
+ --color-background-bright: color-mix(in srgb, var(--color-charcoal-800), var(--color-charcoal-900) calc(var(--theme-contrast, 0) * 100%));
+ --color-background-hover: color-mix(in srgb, var(--color-charcoal-750), var(--color-charcoal-800) calc(var(--theme-contrast, 0) * 100%));
+ /* Mirrors background-hover, as it does at contrast 0 */
+ --color-surface-hover-subtle: color-mix(in srgb, var(--color-charcoal-750), var(--color-charcoal-800) calc(var(--theme-contrast, 0) * 100%));
+ --color-background-raised: color-mix(in srgb, var(--color-charcoal-700), var(--color-charcoal-775) calc(var(--theme-contrast, 0) * 100%));
+ --color-input-bg: color-mix(in srgb, var(--color-charcoal-750), var(--color-charcoal-800) calc(var(--theme-contrast, 0) * 100%));
+
+ /* Controls and borders travel toward the light end */
+ --color-surface-control: color-mix(in srgb, var(--color-charcoal-600), var(--color-charcoal-500) calc(var(--theme-contrast, 0) * 100%));
+ --color-surface-control-hover: color-mix(in srgb, var(--color-charcoal-550), var(--color-charcoal-400) calc(var(--theme-contrast, 0) * 100%));
+ --color-surface-control-active: color-mix(in srgb, var(--color-charcoal-500), var(--color-charcoal-300) calc(var(--theme-contrast, 0) * 100%));
+ --color-grid-dimmed: color-mix(in srgb, var(--color-charcoal-750), var(--color-charcoal-650) calc(var(--theme-contrast, 0) * 100%));
+ --color-grid-bright: color-mix(in srgb, var(--color-charcoal-700), var(--color-charcoal-600) calc(var(--theme-contrast, 0) * 100%));
+ --color-border-bright: color-mix(in srgb, var(--color-charcoal-600), var(--color-charcoal-500) calc(var(--theme-contrast, 0) * 100%));
+ --color-border-brighter: color-mix(in srgb, var(--color-charcoal-550), var(--color-charcoal-400) calc(var(--theme-contrast, 0) * 100%));
+ --color-border-brightest: color-mix(in srgb, var(--color-charcoal-500), var(--color-charcoal-300) calc(var(--theme-contrast, 0) * 100%));
+}
+
+/*
+ Light travels the same way, with one wrinkle: light's greys are its own
+ hand-picked set, not charcoal stops - but they sit on the SAME cool scale and
+ continue straight into it (text-faint is c-400, text-dimmed c-500, text-bright
+ c-800), with the same tint curve. So the scale for light is its pale greys at
+ the top and the charcoal ramp below them.
+
+ Light's pale end is densely sampled while the mid-greys it travels into are
+ charcoal's sparser stops, so a "step" is not a fixed perceptual distance here.
+ Destinations are therefore chosen per token to land near where that token sits
+ today - which mirrors the old per-token weights (25%, 28%, 32%, 38%...) rather
+ than replacing them with one number. Ordering is enforced within each group;
+ groups already crossed each other at high contrast before this change.
+
+ Backgrounds stay among light's pale greys, since charcoal has nothing between
+ c-100 and c-200 to move them into. Everything else lands on a charcoal stop.
+
+ Text is the exception to matching today: at full contrast the old mixing sent
+ text-bright to pure #000 and text-dimmed to #0e0f11, only 1.10:1 apart, so the
+ bright/dimmed hierarchy disappeared. It now moves two stops like the dark
+ side, which keeps the three text tones spaced as they are at contrast 0.
*/
-[data-theme="dark"] {
- /* 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%));
- --color-text-faint: color-mix(in srgb, var(--color-charcoal-500), #fff calc(var(--theme-contrast, 0) * 45%));
-
- /* Backgrounds deepen toward black, keeping their relative order */
- --color-background-deep: color-mix(in srgb, var(--color-charcoal-900), #000 calc(var(--theme-contrast, 0) * 60%));
- --color-background-dimmed: color-mix(in srgb, var(--color-charcoal-850), #000 calc(var(--theme-contrast, 0) * 55%));
- --color-background-bright: color-mix(in srgb, var(--color-charcoal-800), #000 calc(var(--theme-contrast, 0) * 45%));
- --color-background-hover: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%));
- --color-surface-hover-subtle: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%));
- --color-background-raised: color-mix(in srgb, var(--color-charcoal-700), #000 calc(var(--theme-contrast, 0) * 25%));
- --color-input-bg: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%));
-
- /* Controls and borders step up toward white */
- --color-surface-control: color-mix(in srgb, var(--color-charcoal-600), #fff calc(var(--theme-contrast, 0) * 12%));
- --color-surface-control-hover: color-mix(in srgb, var(--color-charcoal-550), #fff calc(var(--theme-contrast, 0) * 14%));
- --color-surface-control-active: color-mix(in srgb, var(--color-charcoal-500), #fff calc(var(--theme-contrast, 0) * 16%));
- --color-grid-dimmed: color-mix(in srgb, var(--color-charcoal-750), #fff calc(var(--theme-contrast, 0) * 18%));
- --color-grid-bright: color-mix(in srgb, var(--color-charcoal-700), #fff calc(var(--theme-contrast, 0) * 20%));
- --color-border-bright: color-mix(in srgb, var(--color-charcoal-600), #fff calc(var(--theme-contrast, 0) * 24%));
- --color-border-brighter: color-mix(in srgb, var(--color-charcoal-550), #fff calc(var(--theme-contrast, 0) * 27%));
- --color-border-brightest: color-mix(in srgb, var(--color-charcoal-500), #fff calc(var(--theme-contrast, 0) * 30%));
-}
-
-[data-theme="light"] {
- /* 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%));
- --color-text-faint: color-mix(in srgb, var(--color-charcoal-400), #000 calc(var(--theme-contrast, 0) * 70%));
-
- /* On white the foregrounds carry the contrast: cards stay white while the
- page-behind surfaces darken a touch so panels separate */
- --color-background-deep: color-mix(in srgb, #f1f2f4, #000 calc(var(--theme-contrast, 0) * 10%));
- --color-background-hover: color-mix(in srgb, #f2f3f5, #000 calc(var(--theme-contrast, 0) * 8%));
- /* Radio card surfaces take no ramp at all on white - their contrast is
- carried by border-selected below. Deliberately absent: surface-hover-subtle,
- surface-selected, surface-selected-hover. */
- --color-background-raised: color-mix(in srgb, #e9eaee, #000 calc(var(--theme-contrast, 0) * 10%));
-
- /* Controls and borders push hard toward black - this is where light-mode
- contrast is actually visible */
- --color-surface-control: color-mix(in srgb, #dcdee3, #000 calc(var(--theme-contrast, 0) * 25%));
- --color-surface-control-hover: color-mix(in srgb, #cfd2d9, #000 calc(var(--theme-contrast, 0) * 28%));
- --color-surface-control-active: color-mix(in srgb, #b8bcc6, #000 calc(var(--theme-contrast, 0) * 32%));
- --color-grid-dimmed: color-mix(in srgb, #eceef1, #000 calc(var(--theme-contrast, 0) * 28%));
- --color-grid-bright: color-mix(in srgb, #e2e4e9, #000 calc(var(--theme-contrast, 0) * 32%));
+:is([data-theme="light"], [data-theme="white"]) {
+ /* Text deepens down the ramp, holding the gaps it has at contrast 0 */
+ --color-text-bright: color-mix(in srgb, var(--color-charcoal-800), var(--color-charcoal-900) calc(var(--theme-contrast, 0) * 100%));
+ --color-text-dimmed: color-mix(in srgb, var(--color-charcoal-500), var(--color-charcoal-600) calc(var(--theme-contrast, 0) * 100%));
+ --color-text-faint: color-mix(in srgb, var(--color-charcoal-400), var(--color-charcoal-500) calc(var(--theme-contrast, 0) * 100%));
+
+ /* Surfaces. Light has nowhere brighter to go than the white it already sits
+ on, so the whole stack deepens - which is what buys panel separation here
+ rather than text contrast.
+
+ Destinations are chosen by LUMINANCE, not by counting stops. The scale is
+ unevenly spaced up here, so equal step counts squeezed the two surfaces that
+ matter most: background-bright (the side menu and title bar) and
+ background-dimmed (the page) sit 0.042 apart at contrast 0, and equal steps
+ closed that to 0.005 by 100% - the two read as one colour exactly when the
+ user asked for more definition. Each tier now drops about 0.13 luminance, so
+ the gaps survive the trip and bright stays clearly above dimmed:
+
+ bright 1.000 -> 0.862 dimmed 0.958 -> 0.822 (gap 0.042 -> 0.040)
+ hover 0.890 -> 0.780 deep 0.885 -> 0.740
+ raised 0.822 -> 0.693
+
+ These stay among light's own pale greys: charcoal has nothing between c-100
+ and c-200 to hold them. White pins all six afterwards, so it stays flat. */
+ --color-background-bright: color-mix(in srgb, #ffffff, #eef0f3 calc(var(--theme-contrast, 0) * 100%));
+ --color-background-dimmed: color-mix(in srgb, #fbfbfc, #e9eaee calc(var(--theme-contrast, 0) * 100%));
+ --color-background-hover: color-mix(in srgb, #f2f3f5, #e2e4e9 calc(var(--theme-contrast, 0) * 100%));
+ --color-background-deep: color-mix(in srgb, #f1f2f4, #dcdee3 calc(var(--theme-contrast, 0) * 100%));
+ --color-background-raised: color-mix(in srgb, #e9eaee, var(--color-charcoal-200) calc(var(--theme-contrast, 0) * 100%));
+ --color-input-bg: color-mix(in srgb, #ffffff, #eef0f3 calc(var(--theme-contrast, 0) * 100%));
+
+ /* Controls, grid lines and borders push down onto the charcoal stops - this is
+ where light-mode contrast is actually visible, and where mixing toward #000
+ did the most damage: border-bright used to land on a +6 blue lean where the
+ ramp's own grey at that brightness carries +18. */
+ --color-surface-control: color-mix(in srgb, #dcdee3, var(--color-charcoal-300) calc(var(--theme-contrast, 0) * 100%));
+ --color-surface-control-hover: color-mix(in srgb, #cfd2d9, var(--color-charcoal-400) calc(var(--theme-contrast, 0) * 100%));
+ --color-surface-control-active: color-mix(in srgb, #b8bcc6, var(--color-charcoal-500) calc(var(--theme-contrast, 0) * 100%));
+ /* Grid lines stop a tier short of where the controls and borders end up. They
+ divide rows and sections rather than outlining anything, so at full contrast
+ charcoal-400 read as ruled paper; these land soft but still legible against
+ the page (grid-bright 1.65:1, grid-dimmed 1.25:1 at 100%). */
+ --color-grid-dimmed: color-mix(in srgb, #eceef1, #cfd2d9 calc(var(--theme-contrast, 0) * 100%));
+ --color-grid-bright: color-mix(in srgb, #e2e4e9, var(--color-charcoal-300) calc(var(--theme-contrast, 0) * 100%));
/* Steeper than border-bright so the selected card stays the loudest edge */
- --color-border-selected: color-mix(in srgb, #e0e2e6, #000 calc(var(--theme-contrast, 0) * 45%));
- --color-border-bright: color-mix(in srgb, #d2d5db, #000 calc(var(--theme-contrast, 0) * 38%));
- --color-border-brighter: color-mix(in srgb, #b9bdc7, #000 calc(var(--theme-contrast, 0) * 42%));
- --color-border-brightest: color-mix(in srgb, #9ba1ad, #000 calc(var(--theme-contrast, 0) * 46%));
+ --color-border-selected: color-mix(in srgb, #e0e2e6, var(--color-charcoal-500) calc(var(--theme-contrast, 0) * 100%));
+ --color-border-bright: color-mix(in srgb, #d2d5db, var(--color-charcoal-400) calc(var(--theme-contrast, 0) * 100%));
+ --color-border-brighter: color-mix(in srgb, #b9bdc7, var(--color-charcoal-500) calc(var(--theme-contrast, 0) * 100%));
+ --color-border-brightest: color-mix(in srgb, #9ba1ad, var(--color-charcoal-550) calc(var(--theme-contrast, 0) * 100%));
+}
+
+/*
+ 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"],
diff --git a/apps/webapp/app/utils/backstopPromise.ts b/apps/webapp/app/utils/backstopPromise.ts
new file mode 100644
index 00000000000..d15d2f60057
--- /dev/null
+++ b/apps/webapp/app/utils/backstopPromise.ts
@@ -0,0 +1,15 @@
+/**
+ * Marks a deliberately un-awaited promise as handled without consuming it.
+ *
+ * For promises created eagerly and consumed later — handed to Remix `defer()`,
+ * or composed into another promise after further awaits — a rejection that
+ * lands before the consumer subscribes counts as an unhandled rejection, and
+ * Node brings the whole process down on those. The no-op catch here is a
+ * separate branch: awaiting the returned promise still rejects as normal
+ * (e.g. into a ``), and a branch nobody ever awaits
+ * simply logs nothing instead of crashing the server.
+ */
+export function backstopPromise(promise: Promise): Promise {
+ promise.catch(() => {});
+ return promise;
+}
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/logUtils.ts b/apps/webapp/app/utils/logUtils.ts
index 140106757b0..85b635be6f2 100644
--- a/apps/webapp/app/utils/logUtils.ts
+++ b/apps/webapp/app/utils/logUtils.ts
@@ -91,20 +91,24 @@ export function kindToLevel(kind: string, status: string): LogLevel {
}
}
-// Level badge color styles
+/* Level badge styles. Every chip is a translucent wash of its own accent over
+ whatever surface it lands on, so the set reads as one family and follows the
+ theme - DEBUG included, which used to be the only solid tile in the row and so
+ stayed a dark chip in a white table. Accents come from tokens rather than the
+ raw palette so the "Increase color contrast" preference can reach them. */
export function getLevelColor(level: LogLevel): string {
switch (level) {
case "ERROR":
- return "text-error bg-error/10 border-error/20";
+ return "text-error bg-error/10 border-error/20 system:border-transparent system:bg-error system:text-white";
case "WARN":
- return "text-warning bg-warning/10 border-warning/20";
+ return "text-warning bg-warning/10 border-warning/20 system:border-transparent system:bg-warning system:text-white";
case "TRACE":
- return "text-purple-400 bg-purple-500/10 border-purple-500/20";
+ return "log-level-chip-trace text-log-trace bg-log-trace/10 border-log-trace/20 system:border-transparent system:bg-log-trace system:text-white";
case "DEBUG":
- return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
+ return "text-text-dimmed bg-black/5 border-black/10 dark:bg-white/5 dark:border-white/10 system:border-transparent system:bg-charcoal-500 system:text-white";
case "INFO":
- return "text-blue-400 bg-blue-500/10 border-blue-500/20";
+ return "text-pending bg-pending/10 border-pending/20 system:border-transparent system:bg-pending system:text-white";
default:
- return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
+ return "text-text-dimmed bg-black/5 border-black/10 dark:bg-white/5 dark:border-white/10 system:border-transparent system:bg-charcoal-500 system:text-white";
}
}
diff --git a/apps/webapp/app/utils/themePreference.ts b/apps/webapp/app/utils/themePreference.ts
index a23f6cba999..49a6f8f1402 100644
--- a/apps/webapp/app/utils/themePreference.ts
+++ b/apps/webapp/app/utils/themePreference.ts
@@ -2,19 +2,54 @@ 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(["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). */
+ * values fall back to `dark`, which is the default. This is also the upgrade
+ * path off the removed `classic` theme: a stored "classic" no longer parses, so
+ * it lands here and resolves to Dark - which at contrast 0 renders the exact
+ * palette Classic used to. Pinned rather than system-resolved, so nobody gets
+ * surprised by light mode. */
export function normalizeThemePreference(value: unknown): ThemePreference {
const result = ThemePreference.safeParse(value);
return result.success ? result.data : "dark";
}
-/** The default dark theme ships with a slight contrast bump. */
-const DEFAULT_THEME_CONTRAST = 50;
+/** Dark's palette at contrast 0 is exactly the palette the Classic theme
+ * shipped, so 0 is the default: anyone arriving from Classic sees no change,
+ * and the slider only ever adds contrast on top. */
+const DEFAULT_THEME_CONTRAST = 0;
+
+/** The "Distinguish without color" accessibility preference: on swaps the
+ * default accents for the high-contrast set (solid badges, monochrome nav
+ * icons, darker chart series). Off is the default. Stored as `iconContrast`,
+ * which predates the preference covering charts and shapes too. */
+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. */