diff --git a/apps/webapp/app/assets/icons/AppearanceIcon.tsx b/apps/webapp/app/assets/icons/AppearanceIcon.tsx new file mode 100644 index 0000000000..f1fd1451fe --- /dev/null +++ b/apps/webapp/app/assets/icons/AppearanceIcon.tsx @@ -0,0 +1,23 @@ +/** Circle with one half filled — the theme/appearance setting. */ +export function AppearanceIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/CircleFilledIcon.tsx b/apps/webapp/app/assets/icons/CircleFilledIcon.tsx new file mode 100644 index 0000000000..a6d10485be --- /dev/null +++ b/apps/webapp/app/assets/icons/CircleFilledIcon.tsx @@ -0,0 +1,16 @@ +/** Solid circle. Paired with {@link CircleOutlineIcon} by the Black and White + * theme options — the filled disc reads as the opposite of the active theme. */ +export function CircleFilledIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx b/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx new file mode 100644 index 0000000000..e60de21907 --- /dev/null +++ b/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx @@ -0,0 +1,16 @@ +/** Hollow circle. Paired with {@link CircleFilledIcon} by the Black and White + * theme options, which show the active theme's background through the ring. */ +export function CircleOutlineIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MonitorIcon.tsx b/apps/webapp/app/assets/icons/MonitorIcon.tsx new file mode 100644 index 0000000000..09aae27984 --- /dev/null +++ b/apps/webapp/app/assets/icons/MonitorIcon.tsx @@ -0,0 +1,21 @@ +/** Monitor on a stand — the System theme, which follows the OS appearance. */ +export function MonitorIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoonIcon.tsx b/apps/webapp/app/assets/icons/MoonIcon.tsx new file mode 100644 index 0000000000..f3e20e27f2 --- /dev/null +++ b/apps/webapp/app/assets/icons/MoonIcon.tsx @@ -0,0 +1,21 @@ +/** Crescent moon — the dark theme. */ +export function MoonIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SunIcon.tsx b/apps/webapp/app/assets/icons/SunIcon.tsx new file mode 100644 index 0000000000..b2ac93fd02 --- /dev/null +++ b/apps/webapp/app/assets/icons/SunIcon.tsx @@ -0,0 +1,34 @@ +/** + * Sun with rays — the light theme. The source artwork wrapped this in a mask and + * a clip path; both were no-ops at this viewBox, and dropping them keeps the + * markup free of ids that would collide when the icon renders more than once. + */ +export function SunIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx b/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx new file mode 100644 index 0000000000..51b8136e1d --- /dev/null +++ b/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx @@ -0,0 +1,23 @@ +/** Toggle switch, knob to the left. */ +export function ToggleSwitchIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/images/producthunt.png b/apps/webapp/app/assets/images/producthunt.png deleted file mode 100644 index e27a96f697..0000000000 Binary files a/apps/webapp/app/assets/images/producthunt.png and /dev/null differ diff --git a/apps/webapp/app/components/ProductHuntBanner.tsx b/apps/webapp/app/components/ProductHuntBanner.tsx deleted file mode 100644 index abb5a14635..0000000000 --- a/apps/webapp/app/components/ProductHuntBanner.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import productHuntLogo from "../assets/images/producthunt.png"; -import { ArrowRightIcon } from "@heroicons/react/20/solid"; -import { Paragraph } from "./primitives/Paragraph"; -import { LinkButton } from "./primitives/Buttons"; - -export function ProductHuntBanner() { - return ( -
- - We're live on{" "} - - Product Hunt - - - Vote for us today only! - -
- ); -} diff --git a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx index e6362d4cb1..125ae688df 100644 --- a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx +++ b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx @@ -20,6 +20,7 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; import type { BillingLimitResult } from "~/services/billingLimit.schemas"; import { formatCurrency } from "~/utils/numberFormatter"; +import { TextLink } from "~/components/primitives/TextLink"; export const billingLimitFormSchema = z.discriminatedUnion("mode", [ z.object({ @@ -338,10 +339,7 @@ function LimitReachedCalloutContent({ When this limit is reached, queued runs will be held for {gracePeriodLabel}, then new triggers will be rejected until you increase or remove the limit. Limits are enforced with a short delay, so spend may briefly exceed the limit before grace begins. See our{" "} - - terms - {" "} - for refund policy details. + terms for refund policy details. {cancelInProgressRuns ? ( <> In-progress runs will be cancelled when the limit is hit. ) : null} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx index cc1370c12a..3ecfba0fe9 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx @@ -54,11 +54,13 @@ export function DashboardAgentComposer({ const sendButton = isStreaming ? ( diff --git a/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx b/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx index 3054a0cdf4..d1049f16c0 100644 --- a/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx +++ b/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx @@ -240,7 +240,7 @@ export function PurchaseSchedulesModal({ type="submit" disabled={isLoading} > - {`Send request for ${formatNumber( + {`Send request for ${formatNumber( amountValue )}`} @@ -254,7 +254,7 @@ export function PurchaseSchedulesModal({ disabled={isLoading || state === "need_to_delete"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Remove ${formatNumber( + {`Remove ${formatNumber( extraSchedules - amountValue )} ${extraSchedules - amountValue === 1 ? "schedule" : "schedules"}`} @@ -268,7 +268,7 @@ export function PurchaseSchedulesModal({ disabled={isLoading || state === "no_change"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Purchase ${formatNumber( + {`Purchase ${formatNumber( amountValue - extraSchedules )} ${amountValue - extraSchedules === 1 ? "schedule" : "schedules"}`} diff --git a/apps/webapp/app/components/themeOptions.ts b/apps/webapp/app/components/themeOptions.ts new file mode 100644 index 0000000000..a570b1bbec --- /dev/null +++ b/apps/webapp/app/components/themeOptions.ts @@ -0,0 +1,65 @@ +import { type FunctionComponent } from "react"; +import { CircleFilledIcon } from "~/assets/icons/CircleFilledIcon"; +import { CircleOutlineIcon } from "~/assets/icons/CircleOutlineIcon"; +import { MonitorIcon } from "~/assets/icons/MonitorIcon"; +import { MoonIcon } from "~/assets/icons/MoonIcon"; +import { SunIcon } from "~/assets/icons/SunIcon"; +import { type ThemeAppearance } from "~/hooks/useSystemThemeSync"; +import { type ThemePreference } from "~/utils/themePreference"; + +export type ThemeOption = { + value: ThemePreference; + label: string; + icon: FunctionComponent<{ className?: string }>; +}; + +/** The themes offered everywhere, in display order - including the account + * popover's submenu. Shared by every theme picker so the labels and icons + * can't drift apart. */ +export const THEME_OPTIONS: ThemeOption[] = [ + { value: "system", label: "System", icon: MonitorIcon }, + { value: "light", label: "Light", icon: SunIcon }, + { value: "dark", label: "Dark", icon: MoonIcon }, +]; + +/** Light and Dark with their surfaces pinned flat, so grid lines carry the + * layout. Account page only. The icons here are the + * dark-theme pair; `themeOptionIcon` swaps them per active theme. */ +const FLAT_OPTIONS: ThemeOption[] = [ + { value: "white", label: "White", icon: CircleFilledIcon }, + { value: "black", label: "Black", icon: CircleOutlineIcon }, +]; + +/** Every theme, for the account page's full picker. */ +export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, ...FLAT_OPTIONS]; + +export const THEME_OPTIONS_BY_VALUE = Object.fromEntries( + ALL_THEME_OPTIONS.map((option) => [option.value, option]) +) as Record; + +/** + * The icon to draw for an option under the active theme. + * + * Black and White show the active theme's background *through* the circle: the + * option matching the current end of the scale is a ring, so the background + * reads through it, and the opposing one is a solid disc in the foreground + * colour. On a dark theme that makes Black a ring and White a filled disc; on a + * light theme it flips. Every other option has one fixed icon. + */ +export function themeOptionIcon(option: ThemeOption, appearance: ThemeAppearance) { + if (option.value === "black") { + return appearance === "dark" ? CircleOutlineIcon : CircleFilledIcon; + } + if (option.value === "white") { + return appearance === "light" ? CircleOutlineIcon : CircleFilledIcon; + } + return option.icon; +} + +/** The two candidates for each end of the `system` setting. */ +export const SYSTEM_LIGHT_OPTIONS: ThemeOption[] = ALL_THEME_OPTIONS.filter( + (option) => option.value === "light" || option.value === "white" +); +export const SYSTEM_DARK_OPTIONS: ThemeOption[] = ALL_THEME_OPTIONS.filter( + (option) => option.value === "dark" || option.value === "black" +); diff --git a/apps/webapp/app/hooks/useSystemThemeSync.ts b/apps/webapp/app/hooks/useSystemThemeSync.ts index 6b2a678396..13f18d98b5 100644 --- a/apps/webapp/app/hooks/useSystemThemeSync.ts +++ b/apps/webapp/app/hooks/useSystemThemeSync.ts @@ -1,5 +1,80 @@ -import { useEffect } from "react"; -import { type ThemePreference } from "~/utils/themePreference"; +import { useEffect, useState } from "react"; +import { + type SystemDarkTheme, + type SystemLightTheme, + type ThemePreference, +} from "~/utils/themePreference"; + +/** Which theme `system` lands on at each end of the OS setting. */ +export type SystemThemes = { light: SystemLightTheme; dark: SystemDarkTheme }; + +export const DEFAULT_SYSTEM_THEMES: SystemThemes = { light: "light", dark: "dark" }; + +/** Which end of the scale a theme sits on. Classic and Black are dark; White is + * light; `system` follows the OS. */ +export type ThemeAppearance = "dark" | "light"; + +export function themeAppearance( + preference: ThemePreference, + prefersDark: boolean +): ThemeAppearance { + if (preference === "system") return prefersDark ? "dark" : "light"; + return preference === "light" || preference === "white" ? "light" : "dark"; +} + +/** + * The resolved appearance, tracking OS changes while the preference is `system`. + * + * Defaults to dark before the effect runs, matching the SSR fallback in root.tsx, + * so the first client render agrees with the server's. + */ +export function useThemeAppearance(preference: ThemePreference): ThemeAppearance { + const [prefersDark, setPrefersDark] = useState(true); + + useEffect(() => { + if (preference !== "system") return; + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const apply = () => setPrefersDark(media.matches); + apply(); + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + }, [preference]); + + return themeAppearance(preference, prefersDark); +} + +/** + * The theme a preference resolves to. Only `system` needs resolving, and it lands + * on whichever variant the user picked for that end of the OS setting - Light or + * White, Dark or Black. + */ +export function resolveThemePreference( + preference: ThemePreference, + prefersDark: boolean, + systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES +): ThemePreference { + if (preference !== "system") return preference; + return prefersDark ? systemThemes.dark : systemThemes.light; +} + +/** + * Puts a preference on now, resolving `system` against the OS once. Use + * this to apply a theme the moment it's picked: the preference round-trips + * through the server and comes back via the root loader, and anything that waits + * for that is at the mercy of whether the revalidation actually lands. + */ +export function applyThemePreference( + preference: ThemePreference, + systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES +) { + const prefersDark = + preference === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches; + document.documentElement.setAttribute( + "data-theme", + resolveThemePreference(preference, prefersDark, systemThemes) + ); + document.documentElement.setAttribute("data-theme-preference", preference); +} /** * Keeps `data-theme` on in sync with the preference. For `system` it @@ -10,20 +85,26 @@ import { type ThemePreference } from "~/utils/themePreference"; * inline script in root.tsx; downstream consumers react to the `data-theme` * mutation (see useThemeColor). */ -export function useSystemThemeSync(preference: ThemePreference) { +export function useSystemThemeSync( + preference: ThemePreference, + systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES +) { + const { light, dark } = systemThemes; + useEffect(() => { if (preference !== "system") { - document.documentElement.setAttribute("data-theme", preference); + applyThemePreference(preference); return; } const media = window.matchMedia("(prefers-color-scheme: dark)"); const apply = () => { - document.documentElement.setAttribute("data-theme", media.matches ? "dark" : "light"); + document.documentElement.setAttribute("data-theme", media.matches ? dark : light); }; apply(); media.addEventListener("change", apply); return () => media.removeEventListener("change", apply); - }, [preference]); + // Destructured so a fresh object identity each render doesn't re-run this + }, [preference, light, dark]); } diff --git a/apps/webapp/app/hooks/useThemeMode.ts b/apps/webapp/app/hooks/useThemeMode.ts index e2d39d9498..5145409e89 100644 --- a/apps/webapp/app/hooks/useThemeMode.ts +++ b/apps/webapp/app/hooks/useThemeMode.ts @@ -1,7 +1,13 @@ import { useEffect, useState } from "react"; +import { SystemLightTheme } from "~/utils/themePreference"; export type ThemeMode = "dark" | "light"; +/* Which themes read as light. Taken from the enum that also drives the "Light" + end of the `system` preference, so a new theme only has to be classified once + - anything not in here (dark, black) reads as dark. */ +const LIGHT_THEMES = new Set(SystemLightTheme.options); + /** * The active theme's mode, for colors that can't come from a CSS variable. Resolved in an * effect so server and hydration renders agree; `root.tsx` can flip `data-theme` pre-paint. @@ -10,7 +16,8 @@ export function useThemeMode(): ThemeMode { const [mode, setMode] = useState("dark"); useEffect(() => { const resolve = () => { - setMode(document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"); + const theme = document.documentElement.getAttribute("data-theme"); + setMode(theme !== null && LIGHT_THEMES.has(theme) ? "light" : "dark"); }; resolve(); const observer = new MutationObserver(resolve); diff --git a/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts b/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts index f866892469..463e8b7370 100644 --- a/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts @@ -7,6 +7,7 @@ import { type ClickHouse } from "@internal/clickhouse"; import { z } from "zod"; import { $replica } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { backstopPromise } from "~/utils/backstopPromise"; import { singleton } from "~/utils/singleton"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; @@ -97,15 +98,19 @@ export class AgentListPresenter { }; } - // All queries are deferred for streaming - const activeStates = this.#getActiveStates(clickhouse, environmentId, slugs); - const conversationSparklines = this.#getConversationSparklines( - clickhouse, - environmentId, - slugs + // All queries are deferred for streaming. Backstopped: consumers subscribe + // late (or, for some callers, not at all), and an unhandled rejection in + // the gap kills the server. + const activeStates = backstopPromise(this.#getActiveStates(clickhouse, environmentId, slugs)); + const conversationSparklines = backstopPromise( + this.#getConversationSparklines(clickhouse, environmentId, slugs) + ); + const costSparklines = backstopPromise( + this.#getCostSparklines(clickhouse, environmentId, slugs) + ); + const tokenSparklines = backstopPromise( + this.#getTokenSparklines(clickhouse, environmentId, slugs) ); - const costSparklines = this.#getCostSparklines(clickhouse, environmentId, slugs); - const tokenSparklines = this.#getTokenSparklines(clickhouse, environmentId, slugs); return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines }; } diff --git a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts index 1541329884..ab11887d16 100644 --- a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts @@ -9,6 +9,7 @@ import { ClickHouseEnvironmentMetricsRepository, type CurrentRunningStats, } from "~/services/environmentMetricsRepository.server"; +import { backstopPromise } from "~/utils/backstopPromise"; import { singleton } from "~/utils/singleton"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; @@ -84,16 +85,18 @@ export class TaskListPresenter { }); // IMPORTANT: Don't await this, we want to return the promise - // so we can defer the loading of the data. The caller is responsible for - // consuming it — an unconsumed promise here would become an unhandled - // rejection if the underlying query fails. - const runningStats = environmentMetricsRepository.getCurrentRunningStats({ - organizationId, - projectId, - environmentId, - days: 6, - tasks: slugs, - }); + // so we can defer the loading of the data. Backstopped because the caller + // only subscribes after further awaits — with ClickHouse down this rejects + // instantly, and an unhandled rejection kills the whole server. + const runningStats = backstopPromise( + environmentMetricsRepository.getCurrentRunningStats({ + organizationId, + projectId, + environmentId, + days: 6, + tasks: slugs, + }) + ); return { tasks, runningStats }; } diff --git a/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts index 6f98e15447..3eb4230804 100644 --- a/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts @@ -8,6 +8,7 @@ import { import { z } from "zod"; import { $replica } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { backstopPromise } from "~/utils/backstopPromise"; import { singleton } from "~/utils/singleton"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { agentListPresenter, type AgentActiveState } from "./AgentListPresenter.server"; @@ -69,26 +70,34 @@ export class UnifiedTaskListPresenter { const items = toUnifiedItems(taskResult.tasks, agentResult.agents); const allSlugs = items.map((item) => item.slug); + // Both deferred promises below are backstopped: the route only subscribes + // (via typeddefer) after further awaits, so a rejection landing in that + // gap — e.g. ClickHouse refusing connections — would otherwise be an + // unhandled rejection and take the server down. Awaiting them still + // rejects into the route's error elements. const hourlyActivity: Promise = allSlugs.length === 0 ? Promise.resolve({}) - : (async () => { - const clickhouse = await clickhouseFactory.getClickhouseForOrganization( - args.organizationId, - "standard" - ); - return getHourlyTaskActivity(clickhouse, { - organizationId: args.organizationId, - projectId: args.projectId, - environmentId: args.environmentId, - slugs: allSlugs, - }); - })(); + : backstopPromise( + (async () => { + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + args.organizationId, + "standard" + ); + return getHourlyTaskActivity(clickhouse, { + organizationId: args.organizationId, + projectId: args.projectId, + environmentId: args.environmentId, + slugs: allSlugs, + }); + })() + ); - const runningStates: Promise = Promise.all([ - taskResult.runningStats, - agentResult.activeStates, - ]).then(([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates)); + const runningStates: Promise = backstopPromise( + Promise.all([taskResult.runningStats, agentResult.activeStates]).then( + ([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates) + ) + ); return { items, hourlyActivity, runningStates }; } diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 1e550155fd..10cfbe0840 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -20,11 +20,15 @@ import { TimezoneSetter } from "./components/TimezoneSetter"; import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; -import { useSystemThemeSync } from "./hooks/useSystemThemeSync"; +import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync"; import { getImpersonationState } from "./services/impersonation.server"; import { getUser } from "./services/session.server"; import { + normalizeIconContrast, + normalizeSystemDarkTheme, + normalizeSystemLightTheme, normalizeThemeContrast, + normalizeUnderlineLinks, normalizeThemePreference, type ThemePreference, } from "~/utils/themePreference"; @@ -81,20 +85,35 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; const user = await getUser(request); - // Theme switching is feature-flagged; while off, everyone stays on the - // classic theme even if a preference was saved earlier. Admins always get - // the switcher so the team can dogfood before the flag flips. Cached: the - // root loader runs on every document request and client navigation. + // Theme switching is feature-flagged; while off, everyone stays on Dark at + // contrast 0 even if a preference was saved earlier - that pairing renders + // the exact palette the Classic theme used to ship. Admins always get the + // switcher so the team can dogfood before the flag flips. Cached: the root + // loader runs on every document request and client navigation. const showThemeSwitcher = user ? user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })) : false; - // Logged-out pages (login, invites) always render the branded Classic look. + // Logged-out pages (login, invites) always render the branded dark look. const themePreference: ThemePreference = showThemeSwitcher ? normalizeThemePreference(user?.dashboardPreferences.theme) - : "classic"; + : "dark"; const themeContrast = showThemeSwitcher ? normalizeThemeContrast(user?.dashboardPreferences.contrast) : 0; + // The "Distinguish without color" accents. Off by default, and forced off + // with the switcher hidden so logged-out and unflagged pages render the + // standard set. + const iconContrast = showThemeSwitcher + ? normalizeIconContrast(user?.dashboardPreferences.iconContrast) + : false; + const underlineLinks = showThemeSwitcher + ? normalizeUnderlineLinks(user?.dashboardPreferences.underlineLinks) + : false; + // Which theme `system` lands on at each end of the OS setting. + const systemThemes = { + light: normalizeSystemLightTheme(user?.dashboardPreferences.systemLightTheme), + dark: normalizeSystemDarkTheme(user?.dashboardPreferences.systemDarkTheme), + }; // Display-only: while impersonating, an admin can ask to see the dashboard // the way the impersonated user sees it. Exposed from root so every route can // read it. @@ -124,7 +143,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { kapa, timezone, showThemeSwitcher, + iconContrast, + underlineLinks, themePreference, + systemThemes, themeContrast, // Consumed by ResizablePanel: the browser check must match between SSR // and hydration, so it is derived from the request user-agent. @@ -147,7 +169,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => { export function ErrorBoundary() { return ( <> - + @@ -171,13 +193,20 @@ export function ErrorBoundary() { } export default function App() { - const { posthogProjectKey, posthogUiHost, themePreference, themeContrast } = - useTypedLoaderData(); + const { + posthogProjectKey, + posthogUiHost, + themePreference, + themeContrast, + iconContrast, + underlineLinks, + systemThemes, + } = useTypedLoaderData(); usePostHog(posthogProjectKey, posthogUiHost); - useSystemThemeSync(themePreference); - // SSR falls back to dark for `system`; the inline script below corrects it - // before paint, and useSystemThemeSync keeps it live afterwards. - const resolvedTheme = themePreference === "system" ? "dark" : themePreference; + useSystemThemeSync(themePreference, systemThemes); + // SSR falls back to the dark end for `system`; the inline script below corrects + // it before paint, and useSystemThemeSync keeps it live afterwards. + const resolvedTheme = resolveThemePreference(themePreference, true, systemThemes); return ( <> @@ -188,13 +217,21 @@ export default function App() { suppressHydrationWarning data-theme={resolvedTheme} data-theme-preference={themePreference} + // Read by the pre-paint script below, which resolves `system` before the + // loader data is available to JS + data-system-light={systemThemes.light} + data-system-dark={systemThemes.dark} + // Accent set for icons and badges; the `system:` variant keys off this + data-icon-contrast={iconContrast ? "true" : "false"} + // Underlines links carrying the inline-text-link marker class + data-underline-links={underlineLinks ? "true" : "false"} // Contrast overlay input for the System themes; Classic never reads it style={{ "--theme-contrast": themeContrast / 100 } as CSSProperties} >