| author | |
| committer | |
| log | f131228a5908974c90d9b80a4d8ef866bb29e973 |
| tree | c439feee41581d111225ff62402af02f6109484a |
| parent | e321667b100a18d5306f845c655dce6f20525776 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 132 insertions(+), 2 deletions(-)
apps/web/src/components/clerk/clerkAppearance.test.ts created+91| ... | @@ -0,0 +1,91 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | EMBER_THEME, | ||
| 5 | GROVE_THEME, | ||
| 6 | IRIS_THEME, | ||
| 7 | OCEAN_THEME, | ||
| 8 | T3_CHAT_THEME, | ||
| 9 | themeColorToHex, | ||
| 10 | type ThemeColors, | ||
| 11 | } from "../../themePalette"; | ||
| 12 | import { clerkAppearance } from "./clerkAppearance"; | ||
| 13 | |||
| 14 | function 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 | |||
| 31 | function 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 | |||
| 43 | const 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 | |||
| 53 | describe("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 @@ | ||
| 1 | import 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. */ | ||
| 6 | export 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"; |
| 18 | import { AppRoot } from "./AppRoot"; | 18 | import { AppRoot } from "./AppRoot"; |
| 19 | import { clerkAppearance } from "./components/clerk/clerkAppearance"; | ||
| 19 | 20 | ||
| 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. |
| 21 | const history = isElectron ? createHashHistory() : createBrowserHistory(); | 22 | const 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 | ) |