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(--contrast-foreground)",
63 colorForeground: "var(--contrast-foreground)",
64 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--contrast-foreground))",
65 colorMutedForeground: "var(--contrast-muted-foreground)",
66 colorBackground: "var(--card)",
67 colorInputForeground: "var(--contrast-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});