{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..94f4403839b
--- /dev/null
+++ b/apps/webapp/app/routes/storybook.usage/route.tsx
@@ -0,0 +1,57 @@
+import { UsageSparkline } from "~/components/primitives/UsageSparkline";
+import { Story, StoryGrid, StoryPage, StorySection } from "../storybook/StoryKit";
+
+/* Fixed start so the tooltips read the same on every render, matching the other
+ chart stories. Midnight UTC, so the shape below lines up with the clock. */
+const BUCKET_START_MS = Date.UTC(2025, 0, 1);
+
+/* 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)}
+ bucketStartMs={BUCKET_START_MS}
+ 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..67a27c99367
--- /dev/null
+++ b/apps/webapp/app/routes/storybook/StoryKit.tsx
@@ -0,0 +1,132 @@
+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";
+
+/** The component's file name, copyable on hover. 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..35f51682938 100644
--- a/apps/webapp/app/routes/storybook/route.tsx
+++ b/apps/webapp/app/routes/storybook/route.tsx
@@ -1,265 +1,319 @@
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,
+ });
+};
+
+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;
+}
+
+function useStorybookIconContrast() {
+ const rootData = useTypedRouteLoaderData("root");
+ const [iconContrast, setIconContrast] = useState(false);
+
+ const savedIconContrast = useRef(rootData?.iconContrast);
+ // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state.
+ 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 restore isn't re-run on data revalidation.
+ const savedPreference = useRef(rootData?.themePreference);
+ const savedSystemThemes = useRef(rootData?.systemThemes);
+ // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state.
+ savedPreference.current = rootData?.themePreference;
+ // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state.
+ 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..2710145b0f1 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,100 @@ 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}
+ `;
+}
+
+/** `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. The key is a checked union, never caller 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..988979adad4
--- /dev/null
+++ b/apps/webapp/app/services/profileUpdateRateLimiter.server.ts
@@ -0,0 +1,27 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { type RedisWithClusterOptions } from "~/redis.server";
+import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
+import { singleton } from "~/utils/singleton";
+
+// Every profile row writes on its own, with no submit button pacing them. The
+// client debounce is a courtesy a scripted POST skips, and `/account` sits
+// outside `/api/*` so apiRateLimiter doesn't cover it. Exported for the tests.
+const PROFILE_UPDATE_RATE_LIMIT_ATTEMPTS = 20;
+const PROFILE_UPDATE_RATE_LIMIT_WINDOW = "1 m" as const;
+
+/** Production uses the env-derived Redis; tests inject a container one. */
+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..afae8d60579
--- /dev/null
+++ b/apps/webapp/app/services/ssoManagedIdentity.server.ts
@@ -0,0 +1,83 @@
+import type { OrgSsoStatus } from "@trigger.dev/plugins";
+import { prisma } from "~/db.server";
+import { logger } from "~/services/logger.server";
+import { ssoController } from "~/services/sso.server";
+
+/**
+ * Who owns a user's email address.
+ *
+ * - `user` - theirs to change.
+ * - `idp` - an identity provider asserts it, so changing it here would break
+ * their next login.
+ * - `unknown` - SSO couldn't be reached. Refuse the write, but don't claim an IdP
+ * owns it.
+ */
+export type EmailOwnership = "user" | "idp" | "unknown";
+
+/**
+ * An org owns a member's email only when SSO is enforced, a connection is live,
+ * and the member's domain is one the org has verified. Enforcement alone isn't
+ * enough: members on other domains (contractors) keep their own sign-in, so
+ * their address is still theirs.
+ */
+export function idpOwnsEmailDomain(status: OrgSsoStatus, emailDomain: string): boolean {
+ if (!status.enforced) return false;
+ if (!status.connections.some((connection) => connection.state === "active")) return false;
+ return status.domains.some(
+ (domain) => domain.verified && domain.domain.toLowerCase() === emailDomain
+ );
+}
+
+function domainOf(email: string): string | undefined {
+ const domain = email.toLowerCase().trim().split("@")[1];
+ return domain || undefined;
+}
+
+export async function getEmailOwnership(user: {
+ id: string;
+ email: string;
+}): Promise
{
+ if (!(await ssoController.isUsingPlugin())) {
+ return "user";
+ }
+
+ const emailDomain = domainOf(user.email);
+ if (!emailDomain) {
+ return "user";
+ }
+
+ const memberships = await prisma.orgMember.findMany({
+ where: { userId: user.id, organization: { deletedAt: null } },
+ select: { organizationId: true },
+ });
+
+ if (memberships.length === 0) {
+ return "user";
+ }
+
+ const statuses = await Promise.all(
+ memberships.map((membership) => ssoController.getStatus(membership.organizationId))
+ );
+
+ // A definite answer from any org wins over an org we couldn't read, so one
+ // unreachable org doesn't mask a real IdP claim - or block a write on its own.
+ let unreadable = false;
+
+ for (const [index, status] of statuses.entries()) {
+ if (status.isErr()) {
+ unreadable = true;
+ logger.warn("SSO status lookup failed; can't establish email ownership", {
+ userId: user.id,
+ organizationId: memberships[index].organizationId,
+ reason: status.error,
+ });
+ continue;
+ }
+
+ if (idpOwnsEmailDomain(status.value, emailDomain)) {
+ return "idp";
+ }
+ }
+
+ return unreadable ? "unknown" : "user";
+}
diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css
index 6a303778fd2..fdacb677ca1 100644
--- a/apps/webapp/app/tailwind.css
+++ b/apps/webapp/app/tailwind.css
@@ -141,10 +141,13 @@
*/
@theme {
/* Text colors */
- --color-primary: var(--color-apple-500);
+ --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);
+ --color-text-link-hover: var(--color-lavender-300);
+ /* One value for every theme: white content on this fill needs 4.5:1. */
+ --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);
@@ -156,6 +159,8 @@
--color-background-hover: var(--color-charcoal-750);
--color-surface-hover-subtle: var(--color-charcoal-750);
--color-background-raised: var(--color-charcoal-700);
+
+ --color-segmented-track: color-mix(in srgb, var(--color-background-raised) 50%, transparent);
--color-surface-selected: var(--color-charcoal-650);
--color-surface-selected-hover: var(--color-charcoal-600);
--color-surface-control: var(--color-charcoal-600);
@@ -166,8 +171,6 @@
/* Borders, from subtlest to most visible */
--color-grid-dimmed: var(--color-charcoal-750);
--color-grid-bright: var(--color-charcoal-700);
- /* Blend of border-bright over the selected card, so it tracks whatever ramp
- border-bright is on - the dark themes need no contrast entry of their own */
--color-border-selected: color-mix(in srgb, var(--color-border-bright) 50%, var(--color-surface-selected));
--color-border-bright: var(--color-charcoal-600);
--color-border-brighter: var(--color-charcoal-550);
@@ -175,13 +178,15 @@
--color-success: var(--color-mint-500);
--color-pending: var(--color-blue-500);
--color-warning: var(--color-amber-500);
+
+ --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);
+ --color-preview: var(--color-blue-500);
/* Icon colors */
--color-tasks: var(--color-blue-500);
@@ -213,7 +218,7 @@
--color-sessions: var(--color-pink-500);
--color-playgrounds: var(--color-fuchsia-500);
--color-models: var(--color-violet-500);
- --color-previewBranches: var(--color-yellow-500);
+ --color-previewBranches: var(--color-blue-500);
}
/* Callout variant accents (see components/primitives/Callout.tsx) */
@@ -238,28 +243,25 @@
*/
/*
- 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. */
--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 +270,6 @@
--color-errors: var(--color-amber-600);
--color-apiKeys: var(--color-amber-600);
- /* Queue charts read blue here; Classic keeps the queues purple */
--color-queues-chart: var(--color-blue-500);
--color-queues-chart-ref: var(--color-charcoal-500);
@@ -277,8 +278,6 @@
--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
- kept (waiting-for-deploy < pending-version < paused) */
--color-run-waiting-for-deploy: var(--color-amber-700);
--color-run-pending-version: #c76508;
--color-run-paused: var(--color-amber-600);
@@ -286,29 +285,32 @@
--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) {
+/* Underlines body-text links only: the TextLink marker class plus markdown
+ prose links, which can't take a class. */
+[data-underline-links="true"] :is(.inline-text-link, .streamdown-container a) {
+ text-decoration-line: underline;
+ text-underline-offset: 2px;
+}
+
+[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 {
+[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. */
+/* Tinted chips only - chips that fill solid under the preference omit it. */
+[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);
}
+
/*
Code syntax palette - the trigger-dark highlight theme, shared by the shiki
theme (streamdown) and the prism theme (CodeBlock). Consumed from JS via
@@ -376,9 +378,6 @@
--color-editor-scrollbar-thumb-active: #3c4b62;
}
-/* Queue chart series colors - queues purple in Classic, 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 {
--color-queues-chart: var(--color-queues);
--color-queues-chart-ref: #4d525b;
@@ -428,16 +427,12 @@
@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"] *));
+@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 {
@@ -579,7 +574,7 @@
:root {
color-scheme: dark;
}
- [data-theme="light"] {
+ :is([data-theme="light"], [data-theme="white"]) {
color-scheme: light;
}
@@ -592,8 +587,7 @@
--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"]) {
+ :is([data-theme="dark"], [data-theme="black"]) {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
@@ -601,9 +595,7 @@
--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"]) {
+ :is([data-theme="light"], [data-theme="white"]) {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 40%;
--chart-3: 30 80% 47%;
@@ -611,6 +603,11 @@
--chart-5: 340 75% 55%;
}
+ [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 +710,10 @@
& blockquote {
@apply border-l-2 border-charcoal-600 pl-3 my-2 italic;
}
+ /* `no-underline` is load-bearing: streamdown hard-codes `underline` on every
+ anchor, and a class plus type selector outspecifies it. */
& 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;
@@ -743,11 +742,19 @@
}
}
+/* The stored 0-100 is a position within the active theme's own range, mapped
+ here rather than in JS so `system` needs nothing resolved on the client.
+ Ramps read --theme-contrast (strengthen) and --theme-fade (Black only). */
+:root {
+ --theme-contrast: var(--theme-contrast-percent, 0);
+ --theme-fade: 0;
+}
+
/*
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"] {
+:is([data-theme="light"], [data-theme="white"]) {
--color-secondary: #ffffff;
--color-input-bg: #ffffff;
@@ -761,9 +768,9 @@
--primary-foreground: #ffffff;
/* Text */
- --color-primary: var(--color-apple-600);
--color-tertiary: #eef0f3;
--color-text-link: var(--color-lavender-600);
+ --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);
@@ -777,10 +784,9 @@
--color-surface-hover-subtle: #f7f8f9;
--color-background-raised: #e9eaee;
--color-surface-control: #dcdee3;
+ --color-segmented-track: var(--color-background-raised);
--color-surface-control-hover: #cfd2d9;
--color-surface-control-active: #b8bcc6;
- /* Selection sits lighter than the controls here - on white a soft grey is
- already enough to read as selected */
--color-surface-selected: #eff0f2;
--color-surface-selected-hover: #e7e9ec;
@@ -792,11 +798,7 @@
--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. */
- /* 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 */
--color-staging: var(--color-orange-600);
/* Grey chart reference series, light enough not to dominate on white */
@@ -812,8 +814,6 @@
/* Monochrome icon gray - per-mode, not part of the unified accent set */
--color-customDashboards: var(--color-charcoal-500);
- /* Callout text/bg - deep tints instead of the dark theme's pastels; text
- can't unify across modes (see the unified accents block) */
--color-callout-warning-text: var(--color-yellow-800);
--color-callout-error-text: var(--color-rose-700);
--color-callout-success-text: var(--color-green-800);
@@ -880,113 +880,211 @@
--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);
+
+ --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);
+
+ --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;
+}
+
+/* The five pale greys all sit under the 3:1 a chart series needs on white;
+ charcoal-400 is the first stop that clears it. Dark themes are untouched. */
+/* TRACE stays an outline on the light themes so it doesn't compete with
+ ERROR two chips along. 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 {
+[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;
}
}
-/* 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.
-*/
-[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%));
+/* Interface contrast. Text and borders travel toward the light end of the
+ charcoal ramp, backgrounds toward the dark end; accents are untouched. Every
+ token moves between two ramp stops, never toward #fff/#000, so intermediate
+ values stay in the palette. Must stay below the theme blocks to win the
+ cascade. */
+:is([data-theme="dark"], [data-theme="black"]) {
+ --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%));
+
+ --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%));
+ --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%));
+
+ --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's pale greys continue onto the same cool scale, but unevenly, so
+ destinations are picked per token by where it should land rather than by
+ counting stops. */
+:is([data-theme="light"], [data-theme="white"]) {
+ --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%));
+
+ /* Light has nowhere brighter to go, so the stack deepens. Destinations are
+ chosen by luminance: the scale is unevenly spaced here, and equal step
+ counts collapsed the bright/dimmed gap. White pins all six afterwards. */
+ --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%));
+
+ /* Where light-mode contrast is actually visible. */
+ --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%));
+ /* A tier short of the controls: they divide rows rather than outline. */
+ --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 inherit Dark's and Light's token sets and only pin their
+ surfaces flat. Must stay below the contrast blocks so the pins win. */
+[data-theme="black"] {
+ --color-background-deep: #000000;
+ --color-background-dimmed: #000000;
+ --color-background-bright: #000000;
+ /* Black's range is -30..100: r = p * 1.3 - 0.3, split into two always-positive
+ halves because `color-mix` rejects a negative percentage. */
+ --theme-contrast: max(0, var(--theme-contrast-percent, 0) * 1.3 - 0.3);
+ --theme-fade: max(0, 0.3 - var(--theme-contrast-percent, 0) * 1.3);
+
+ /* Grid lines are the only structure on a flat black page, so they get the
+ extra travel below the base palette. */
+ --color-grid-dimmed: color-mix(
+ in srgb,
+ color-mix(
+ in srgb,
+ var(--color-charcoal-750),
+ var(--color-charcoal-650) calc(var(--theme-contrast, 0) * 100%)
+ ),
+ #000000 calc(var(--theme-fade, 0) * 100%)
+ );
+ --color-grid-bright: color-mix(
+ in srgb,
+ color-mix(
+ in srgb,
+ var(--color-charcoal-700),
+ var(--color-charcoal-600) calc(var(--theme-contrast, 0) * 100%)
+ ),
+ #000000 calc(var(--theme-fade, 0) * 100%)
+ );
+
+ --color-background-hover: #171717;
+ --color-background-raised: #1f1f1f;
+ --color-segmented-track: var(--color-background-raised);
+ --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..8246087e534
--- /dev/null
+++ b/apps/webapp/app/utils/backstopPromise.ts
@@ -0,0 +1,9 @@
+/**
+ * Marks a deliberately un-awaited promise as handled without consuming it: a
+ * rejection landing before the consumer subscribes would otherwise take the
+ * process down. Awaiting the returned promise still rejects as normal.
+ */
+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..de858a901c5 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
@@ -50,8 +50,15 @@ const DashboardPreferences = z.object({
/* An unknown value (e.g. written by a newer deploy) degrades to undefined
instead of failing the whole blob and erasing every other setting */
theme: ThemePreference.optional().catch(undefined),
- /** Interface contrast for the System themes, 0-100. */
+ /** 0-100, a position within the active theme's own range. */
contrast: z.number().int().min(0).max(100).optional().catch(undefined),
+ /** Swaps the base 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..04572df1729 100644
--- a/apps/webapp/app/utils/logUtils.ts
+++ b/apps/webapp/app/utils/logUtils.ts
@@ -91,20 +91,21 @@ export function kindToLevel(kind: string, status: string): LogLevel {
}
}
-// Level badge color styles
+/* Each chip is a translucent wash of its own accent, from tokens rather than the
+ raw palette so the "Stronger colors" 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..1bd687dce69 100644
--- a/apps/webapp/app/utils/themePreference.ts
+++ b/apps/webapp/app/utils/themePreference.ts
@@ -1,23 +1,49 @@
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"]);
+// Shared with the client, so no server-only imports.
+export const ThemePreference = z.enum(["system", "dark", "light", "black", "white"]);
export type ThemePreference = z.infer;
-/** 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). */
+/* Which theme `system` resolves to at each end of the OS setting. */
+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";
+}
+
+/** Missing, unknown and legacy values (including the removed `classic`) fall
+ * back to `dark`. */
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;
+/** 0 is the base palette; the slider only ever adds contrast on top. */
+const DEFAULT_THEME_CONTRAST = 0;
+
+/** The "Stronger colors" preference. Stored as `iconContrast`, which predates it
+ * covering charts and shapes too. */
+export function normalizeIconContrast(value: unknown): boolean {
+ return value === true;
+}
+
+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. */
+/**
+ * A 0-100 position within the active theme's own range, not a shared scale, so
+ * 35% stays 35% across themes. Each theme maps it in tailwind.css.
+ */
export function normalizeThemeContrast(value: unknown): number {
const num = typeof value === "string" ? Number(value) : value;
if (typeof num !== "number" || !Number.isFinite(num)) return DEFAULT_THEME_CONTRAST;
diff --git a/apps/webapp/app/v3/featureFlags.server.ts b/apps/webapp/app/v3/featureFlags.server.ts
index b32a4578640..fdd302ede84 100644
--- a/apps/webapp/app/v3/featureFlags.server.ts
+++ b/apps/webapp/app/v3/featureFlags.server.ts
@@ -6,7 +6,9 @@ import {
type FeatureFlagCatalogSchema,
type FeatureFlagKey,
FeatureFlagCatalog,
+ validatePartialFeatureFlags,
} from "~/v3/featureFlags";
+import { env } from "~/env.server";
import { stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
export type FlagsOptions = {
@@ -220,3 +222,34 @@ export async function applyGlobalMintKindFlip(
return makeSetMultipleFlags(tx)(stamped);
});
}
+
+/** The global flag set, with the env-var defaults this app applies. */
+export async function globalFeatureFlags() {
+ return flags({
+ defaultValues: {
+ hasAiAccess: env.AI_FEATURES_ENABLED === "1",
+ hasDashboardAgentAccess: env.DASHBOARD_AGENT_ENABLED === "1",
+ hasPrivateConnections: env.PRIVATE_CONNECTIONS_ENABLED === "1",
+ },
+ });
+}
+
+/** The global set with one org's overrides on top. */
+export function mergeOrgFeatureFlags(
+ globalFlags: Partial,
+ orgFeatureFlags: unknown
+) {
+ const parsed = orgFeatureFlags
+ ? validatePartialFeatureFlags(orgFeatureFlags as Record)
+ : ({ success: false } as const);
+ return { ...globalFlags, ...(parsed.success ? parsed.data : {}) };
+}
+
+/**
+ * The flags that apply to one organization. Server-side callers that need the
+ * same set the side menu sees should use this rather than assembling their own,
+ * so a partial set can't silently disagree with it.
+ */
+export async function resolveOrganizationFeatureFlags(orgFeatureFlags: unknown) {
+ return mergeOrgFeatureFlags(await globalFeatureFlags(), orgFeatureFlags);
+}
diff --git a/apps/webapp/test/ssoManagedIdentity.test.ts b/apps/webapp/test/ssoManagedIdentity.test.ts
new file mode 100644
index 00000000000..fda4f8ec9da
--- /dev/null
+++ b/apps/webapp/test/ssoManagedIdentity.test.ts
@@ -0,0 +1,86 @@
+import type { OrgSsoStatus } from "@trigger.dev/plugins";
+import { describe, expect, it } from "vitest";
+import { idpOwnsEmailDomain } from "~/services/ssoManagedIdentity.server";
+
+function status(overrides: Partial = {}): OrgSsoStatus {
+ return {
+ hasIdpOrg: true,
+ enforced: true,
+ jitProvisioningEnabled: false,
+ jitDefaultRoleId: null,
+ idpOrgId: "idp_123",
+ primaryConnectionId: "conn_123",
+ domains: [
+ { domain: "acme.com", verified: true, state: "verified", verificationFailedReason: null },
+ ],
+ connections: [{ id: "conn_123", name: "Okta", connectionType: "OktaSAML", state: "active" }],
+ ...overrides,
+ };
+}
+
+describe("idpOwnsEmailDomain", () => {
+ it("claims a member on a verified domain of an enforcing org", () => {
+ expect(idpOwnsEmailDomain(status(), "acme.com")).toBe(true);
+ });
+
+ it("leaves a contractor on another domain alone", () => {
+ expect(idpOwnsEmailDomain(status(), "freelance.io")).toBe(false);
+ });
+
+ it("leaves everyone alone until SSO is enforced", () => {
+ expect(idpOwnsEmailDomain(status({ enforced: false }), "acme.com")).toBe(false);
+ });
+
+ it("ignores a domain that hasn't been verified", () => {
+ expect(
+ idpOwnsEmailDomain(
+ status({
+ domains: [
+ {
+ domain: "acme.com",
+ verified: false,
+ state: "pending",
+ verificationFailedReason: null,
+ },
+ ],
+ }),
+ "acme.com"
+ )
+ ).toBe(false);
+ });
+
+ it("ignores an org with no live connection", () => {
+ expect(
+ idpOwnsEmailDomain(
+ status({
+ connections: [
+ { id: "conn_123", name: "Okta", connectionType: "OktaSAML", state: "inactive" },
+ ],
+ }),
+ "acme.com"
+ )
+ ).toBe(false);
+ });
+
+ it("matches domains case-insensitively", () => {
+ expect(
+ idpOwnsEmailDomain(
+ status({
+ domains: [
+ {
+ domain: "ACME.com",
+ verified: true,
+ state: "verified",
+ verificationFailedReason: null,
+ },
+ ],
+ }),
+ "acme.com"
+ )
+ ).toBe(true);
+ });
+
+ it("does not treat a subdomain as the verified domain", () => {
+ expect(idpOwnsEmailDomain(status(), "mail.acme.com")).toBe(false);
+ });
+});
diff --git a/apps/webapp/test/themePreference.test.ts b/apps/webapp/test/themePreference.test.ts
index 420cb2878a4..f89ecf49b79 100644
--- a/apps/webapp/test/themePreference.test.ts
+++ b/apps/webapp/test/themePreference.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { parseDashboardPreferences } from "~/utils/dashboardPreferences";
import { normalizeThemePreference, type ThemePreference } from "~/utils/themePreference";
-const VALID_THEMES: ThemePreference[] = ["classic", "system", "dark", "light"];
+const VALID_THEMES: ThemePreference[] = ["system", "dark", "light", "black", "white"];
describe("normalizeThemePreference", () => {
it("returns each valid value unchanged", () => {
@@ -12,6 +12,9 @@ describe("normalizeThemePreference", () => {
});
it("falls back to dark for legacy/unknown values", () => {
+ // Classic is retired. Anyone still holding it lands on Dark, which at
+ // contrast 0 renders the palette Classic used to ship.
+ expect(normalizeThemePreference("classic")).toBe("dark");
expect(normalizeThemePreference("solarized")).toBe("dark");
expect(normalizeThemePreference("")).toBe("dark");
expect(normalizeThemePreference(42)).toBe("dark");
@@ -24,13 +27,18 @@ describe("normalizeThemePreference", () => {
});
describe("DashboardPreferences theme schema", () => {
- it("accepts all four theme values", () => {
+ it("accepts every theme value", () => {
for (const theme of VALID_THEMES) {
const result = parseDashboardPreferences({ version: "1", projects: {}, theme });
expect(result.theme).toBe(theme);
}
});
+ it("drops a stored classic theme", () => {
+ const result = parseDashboardPreferences({ version: "1", projects: {}, theme: "classic" });
+ expect(result.theme).toBeUndefined();
+ });
+
it("accepts preferences without a theme", () => {
const result = parseDashboardPreferences({ version: "1", projects: {} });
expect(result.theme).toBeUndefined();