authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-12 16:06:04+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-12 19:36:04+05:30
logf131228a5908974c90d9b80a4d8ef866bb29e973
treec439feee41581d111225ff62402af02f6109484a
parente321667b100a18d5306f845c655dce6f20525776
signature Signed by PGP key B5690EEEBB952194

fix(web): theme Clerk surfaces (#6300)


3 files changed, 132 insertions(+), 2 deletions(-)

apps/web/src/components/clerk/clerkAppearance.test.ts created+91
...@@ -0,0 +1,91 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 EMBER_THEME,
5 GROVE_THEME,
6 IRIS_THEME,
7 OCEAN_THEME,
8 T3_CHAT_THEME,
9 themeColorToHex,
10 type ThemeColors,
11} from "../../themePalette";
12import { clerkAppearance } from "./clerkAppearance";
13
14function contrastRatio(first: string, second: string): number {
15 const toRgb = (value: string) => {
16 const hex = themeColorToHex(value)?.slice(1, 7);
17 if (!hex) throw new Error(`Expected a theme color, received ${value}`);
18 return [0, 1, 2].map(
19 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
20 );
21 };
22 const luminance = (value: string) =>
23 toRgb(value)
24 .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
25 .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0);
26 const lighter = Math.max(luminance(first), luminance(second));
27 const darker = Math.min(luminance(first), luminance(second));
28 return (lighter + 0.05) / (darker + 0.05);
29}
30
31function mixThemeColors(first: string, second: string, firstWeight: number): string {
32 const channels = [first, second].map((value) => {
33 const hex = themeColorToHex(value)?.slice(1, 7);
34 if (!hex) throw new Error(`Expected a theme color, received ${value}`);
35 return [0, 1, 2].map((channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16));
36 });
37 const mixed = channels[0]!.map((channel, index) =>
38 Math.round(channel * firstWeight + channels[1]![index]! * (1 - firstWeight)),
39 );
40 return `#${mixed.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
41}
42
43const builtInThemeModes = [
44 T3_CHAT_THEME,
45 GROVE_THEME,
46 OCEAN_THEME,
47 EMBER_THEME,
48 IRIS_THEME,
49].flatMap((theme) =>
50 [theme.colors, theme.variants?.dark].filter((colors): colors is ThemeColors => !!colors),
51);
52
53describe("clerkAppearance", () => {
54 it("maps theme colors without overriding Clerk's component structure", () => {
55 expect(clerkAppearance).toEqual({
56 variables: {
57 colorPrimary: "var(--update-foreground)",
58 colorPrimaryForeground: "var(--card)",
59 colorDanger: "var(--error)",
60 colorSuccess: "var(--success)",
61 colorWarning: "var(--warning)",
62 colorNeutral: "var(--foreground)",
63 colorForeground: "var(--foreground)",
64 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--foreground))",
65 colorMutedForeground: "var(--muted-foreground)",
66 colorBackground: "var(--card)",
67 colorInputForeground: "var(--foreground)",
68 colorInput: "var(--secondary)",
69 colorRing: "var(--ring)",
70 },
71 elements: {
72 formFieldErrorText: { color: "var(--error-foreground)" },
73 formFieldWarningText: { color: "var(--warning-foreground)" },
74 formFieldSuccessText: { color: "var(--success-foreground)" },
75 otpCodeFieldErrorText: { color: "var(--error-foreground)" },
76 otpCodeFieldSuccessText: { color: "var(--success-foreground)" },
77 },
78 });
79 });
80
81 it.each(builtInThemeModes)("keeps Clerk text readable across a built-in palette", (colors) => {
82 const mutedSurface = mixThemeColors(colors.surface, colors.text, 0.98);
83
84 expect(contrastRatio(colors.text, colors.surface)).toBeGreaterThanOrEqual(4.5);
85 expect(contrastRatio(colors.mutedForeground, mutedSurface)).toBeGreaterThanOrEqual(4.5);
86 expect(contrastRatio(colors.text, colors.secondary)).toBeGreaterThanOrEqual(4.5);
87 expect(contrastRatio(colors.updateForeground, colors.surface)).toBeGreaterThanOrEqual(4.5);
88 expect(contrastRatio(colors.errorForeground, colors.surface)).toBeGreaterThanOrEqual(4.5);
89 expect(contrastRatio(colors.warningForeground, colors.surface)).toBeGreaterThanOrEqual(4.5);
90 });
91});
apps/web/src/components/clerk/clerkAppearance.ts created+34
...@@ -0,0 +1,34 @@
1import type { ClerkProviderProps } from "@clerk/react";
2
3/** Keeps Clerk's stock component structure while binding its color system to
4 * the live T3 Code palette. CSS variables make theme changes propagate to
5 * portaled sign-in and profile surfaces without remounting Clerk. */
6export const clerkAppearance = {
7 variables: {
8 // Clerk reuses its primary color for filled buttons and bare links. The
9 // app's update foreground is the palette's action hue cast for readable
10 // text, while the card surface provides the inverse filled-control pair.
11 colorPrimary: "var(--update-foreground)",
12 colorPrimaryForeground: "var(--card)",
13 colorDanger: "var(--error)",
14 colorSuccess: "var(--success)",
15 colorWarning: "var(--warning)",
16 colorNeutral: "var(--foreground)",
17 colorForeground: "var(--foreground)",
18 // The stock dark theme's muted token is translucent. Clerk uses this as
19 // the footer's background, so derive an opaque muted surface from the card.
20 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--foreground))",
21 colorMutedForeground: "var(--muted-foreground)",
22 colorBackground: "var(--card)",
23 colorInputForeground: "var(--foreground)",
24 colorInput: "var(--secondary)",
25 colorRing: "var(--ring)",
26 },
27 elements: {
28 formFieldErrorText: { color: "var(--error-foreground)" },
29 formFieldWarningText: { color: "var(--warning-foreground)" },
30 formFieldSuccessText: { color: "var(--success-foreground)" },
31 otpCodeFieldErrorText: { color: "var(--error-foreground)" },
32 otpCodeFieldSuccessText: { color: "var(--success-foreground)" },
33 },
34} satisfies NonNullable<ClerkProviderProps["appearance"]>;
apps/web/src/main.tsx+7-2
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 syncDocumentWindowControlsOverlayClass,16 syncDocumentWindowControlsOverlayClass,
17} from "./lib/windowControlsOverlay";17} from "./lib/windowControlsOverlay";
18import { AppRoot } from "./AppRoot";18import { AppRoot } from "./AppRoot";
19import { clerkAppearance } from "./components/clerk/clerkAppearance";
1920
20// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.21// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
21const history = isElectron ? createHashHistory() : createBrowserHistory();22const history = isElectron ? createHashHistory() : createBrowserHistory();
...@@ -35,11 +36,15 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(...@@ -35,11 +36,15 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
35 <React.StrictMode>36 <React.StrictMode>
36 {clerkPublishableKey && hasCloudPublicConfig() ? (37 {clerkPublishableKey && hasCloudPublicConfig() ? (
37 isElectron ? (38 isElectron ? (
38 <ElectronClerkProvider publishableKey={clerkPublishableKey} passkeys={passkeys}>39 <ElectronClerkProvider
40 appearance={clerkAppearance}
41 publishableKey={clerkPublishableKey}
42 passkeys={passkeys}
43 >
39 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>44 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>
40 </ElectronClerkProvider>45 </ElectronClerkProvider>
41 ) : (46 ) : (
42 <ClerkProvider publishableKey={clerkPublishableKey}>47 <ClerkProvider appearance={clerkAppearance} publishableKey={clerkPublishableKey}>
43 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>48 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>
44 </ClerkProvider>49 </ClerkProvider>
45 )50 )