| 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(--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 | }); |