1import { describe, expect, it } from "vite-plus/test";
2import {
3 BUILT_IN_THEME_IDS,
4 BUILT_IN_THEMES,
5 T3_CHAT_THEME,
6 T3_CODE_LIGHT_THEME_COLORS,
7 T3_CODE_DARK_THEME_COLORS,
8 MOBILE_THEME_IDS,
9 getThemeColorsForAppearance,
10} from "@t3tools/shared/themePalettes";
11import {
12 readDefaultMobileThemeVariables,
13 readGeneratedMobileThemeVariables,
14} from "./mobileTheme.test-support";
15import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
16
17import {
18 createMobileThemeSelectionPatch,
19 createMobileThemeVariables,
20 DEFAULT_MOBILE_THEME_ID,
21 flattenThemeColor,
22 getMobileThemeVariables,
23 resolveMobileThemeIds,
24 themeColorWithAlpha,
25 themeColorToNativeColor,
26} from "./mobileTheme";
27
28function relativeLuminance(hex: string): number {
29 const channels = hex
30 .slice(1)
31 .match(/.{2}/g)!
32 .map((channel) => Number.parseInt(channel, 16) / 255)
33 .map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
34 return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
35}
36
37function contrastRatio(first: string, second: string): number {
38 const firstLuminance = relativeLuminance(first);
39 const secondLuminance = relativeLuminance(second);
40 return (
41 (Math.max(firstLuminance, secondLuminance) + 0.05) /
42 (Math.min(firstLuminance, secondLuminance) + 0.05)
43 );
44}
45
46function compositeOver(overlay: string, background: string): string {
47 const overlayMatch = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(overlay)!;
48 const backgroundChannels = background
49 .slice(1)
50 .match(/.{2}/g)!
51 .map((channel) => Number.parseInt(channel, 16));
52 const alpha = Number(overlayMatch[4]);
53 const channels = [1, 2, 3].map((index) =>
54 Math.round(Number(overlayMatch[index]) * alpha + backgroundChannels[index - 1]! * (1 - alpha)),
55 );
56 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
57}
58
59describe("mobile themes", () => {
60 it("declares every runtime theme variable in the static stylesheet", () => {
61 const generatedVariables = createMobileThemeVariables(T3_CHAT_THEME.colors, "light");
62 expect(Object.keys(readDefaultMobileThemeVariables("light")).sort()).toEqual(
63 Object.keys(generatedVariables).sort(),
64 );
65 expect(Object.keys(readDefaultMobileThemeVariables("dark")).sort()).toEqual(
66 Object.keys(generatedVariables).sort(),
67 );
68 });
69
70 it.each(MOBILE_THEME_IDS)("uses the web color roles for %s in both appearances", (themeId) => {
71 for (const appearance of ["light", "dark"] as const) {
72 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId);
73 const colors = theme
74 ? getThemeColorsForAppearance(theme, appearance)!
75 : appearance === "dark"
76 ? T3_CODE_DARK_THEME_COLORS
77 : T3_CODE_LIGHT_THEME_COLORS;
78 const variables =
79 themeId === DEFAULT_MOBILE_THEME_ID
80 ? readDefaultMobileThemeVariables(appearance)
81 : getMobileThemeVariables(themeId, appearance);
82 expect(variables["--color-screen"]).toBe(themeColorToNativeColor(colors.canvas));
83 expect(variables["--color-thread-canvas"]).toBe(variables["--color-screen"]);
84 expect(variables["--color-drawer"]).toBe(themeColorToNativeColor(colors.sidebar));
85 expect(variables["--color-thread-hover"]).toBe(
86 themeColorToNativeColor(colors.sidebarRowHover),
87 );
88 expect(variables["--color-card"]).toBe(themeColorToNativeColor(colors.surface));
89 expect(variables["--color-composer-surface"]).toBe(
90 themeColorWithAlpha(variables["--color-grouped-card"], appearance === "dark" ? 0.9 : 0.94),
91 );
92 expect(variables["--color-thread-selected"]).toBe(
93 themeColorToNativeColor(colors.sidebarRowActive),
94 );
95 expect(variables["--color-thread-selected-foreground"]).toBe(
96 themeColorToNativeColor(colors.sidebarForeground),
97 );
98 expect(variables["--color-primary"]).toBe(themeColorToNativeColor(colors.messageAction));
99 if (themeId !== DEFAULT_MOBILE_THEME_ID) {
100 expect(variables["--color-user-bubble"]).toBe(
101 themeColorToNativeColor(colors.messageSurface),
102 );
103 }
104 expect(
105 contrastRatio(variables["--color-foreground"], variables["--color-screen"]),
106 ).toBeGreaterThanOrEqual(4.5);
107 for (const [foreground, surface] of [
108 ["--color-drawer-foreground", "--color-drawer"],
109 ["--color-drawer-foreground-muted", "--color-drawer"],
110 ["--color-drawer-foreground", "--color-thread-hover"],
111 ["--color-drawer-foreground-muted", "--color-thread-hover"],
112 ["--color-thread-selected-foreground-muted", "--color-thread-selected"],
113 ["--color-primary-foreground", "--color-primary"],
114 ["--color-primary-text", "--color-screen"],
115 ["--color-primary-text", "--color-card"],
116 ["--color-primary-text", "--color-card-alt"],
117 ["--color-primary-text", "--color-sheet-solid"],
118 ["--color-foreground", "--color-sheet-solid"],
119 ["--color-foreground-muted", "--color-sheet-solid"],
120 ["--color-primary-text", "--color-grouped-card"],
121 ["--color-foreground", "--color-grouped-card"],
122 ["--color-foreground-muted", "--color-grouped-card"],
123 ["--color-placeholder", "--color-grouped-card"],
124 ["--color-secondary-foreground", "--color-secondary"],
125 ["--color-user-bubble-foreground", "--color-user-bubble"],
126 ["--color-warning-foreground", "--color-warning"],
127 ["--color-danger-foreground", "--color-danger"],
128 ["--color-md-body", "--color-screen"],
129 ["--color-md-strong", "--color-screen"],
130 ["--color-md-link", "--color-screen"],
131 ["--color-md-code-text", "--color-md-code-bg"],
132 ] as const) {
133 expect(
134 contrastRatio(variables[foreground], variables[surface]),
135 `${appearance}: ${foreground} on ${surface}`,
136 ).toBeGreaterThanOrEqual(4.5);
137 }
138 expect(
139 contrastRatio(
140 variables["--color-thread-selected-foreground"],
141 variables["--color-thread-selected"],
142 ),
143 ).toBeGreaterThanOrEqual(4.5);
144 }
145 });
146
147 it.each(MOBILE_THEME_IDS)("keeps %s sidebar text readable on Android", (themeId) => {
148 for (const appearance of ["light", "dark"] as const) {
149 const variables = getMobileThemeRuntimeVariables(themeId, appearance, "android");
150 for (const foreground of [
151 "--color-drawer-foreground",
152 "--color-drawer-foreground-muted",
153 ] as const) {
154 expect(
155 contrastRatio(variables[foreground], variables["--color-drawer"]),
156 `${appearance}: ${foreground}`,
157 ).toBeGreaterThanOrEqual(4.5);
158 }
159 }
160 });
161
162 it.each(MOBILE_THEME_IDS)("separates %s filled groups from their sheets", (themeId) => {
163 for (const appearance of ["light", "dark"] as const) {
164 const generated = readGeneratedMobileThemeVariables(
165 themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`,
166 );
167 expect(generated).toEqual(getMobileThemeVariables(themeId, appearance));
168 for (const platform of ["ios", "android"]) {
169 const variables = getMobileThemeRuntimeVariables(themeId, appearance, platform);
170 const sheet = variables["--color-sheet-solid"];
171 const group = variables["--color-grouped-card"];
172 expect(
173 contrastRatio(group, sheet),
174 `${platform} ${appearance}: grouped surface on sheet`,
175 ).toBeGreaterThanOrEqual(1.025);
176 for (const role of [
177 "--color-foreground",
178 "--color-foreground-secondary",
179 "--color-foreground-muted",
180 "--color-placeholder",
181 "--color-primary-text",
182 ] as const) {
183 expect(
184 contrastRatio(variables[role], group),
185 `${platform} ${appearance}: ${role} on grouped surface`,
186 ).toBeGreaterThanOrEqual(4.5);
187 }
188 if (platform === "android") {
189 const header = flattenThemeColor(variables["--color-header"], sheet);
190 for (const role of ["--color-header-foreground", "--color-foreground-muted"] as const) {
191 expect(
192 contrastRatio(variables[role], header),
193 `${appearance}: ${role} on Android header`,
194 ).toBeGreaterThanOrEqual(4.5);
195 }
196 if (appearance === "dark") {
197 for (const surface of [variables["--color-screen"], header]) {
198 expect(
199 contrastRatio(group, surface),
200 `dark grouped surface on Android ${surface}`,
201 ).toBeGreaterThanOrEqual(1.2);
202 }
203 }
204 }
205 const selected = variables["--color-thread-selected"];
206 expect(
207 contrastRatio(selected, flattenThemeColor(variables["--color-drawer"], sheet)),
208 `${platform} ${appearance}: selected row on drawer`,
209 ).toBeGreaterThanOrEqual(1.2);
210 for (const role of [
211 "--color-thread-selected-foreground",
212 "--color-thread-selected-foreground-muted",
213 ] as const) {
214 expect(
215 contrastRatio(variables[role], selected),
216 `${platform} ${appearance}: ${role} on selected row`,
217 ).toBeGreaterThanOrEqual(4.5);
218 }
219 expect(
220 contrastRatio(variables["--color-foreground-muted"], variables["--color-secondary"]),
221 `${platform} ${appearance}: muted label on secondary surface`,
222 ).toBeGreaterThanOrEqual(4.5);
223 }
224 }
225 });
226
227 it("migrates one theme choice to both appearances and preserves independent choices", () => {
228 expect(resolveMobileThemeIds({ themeId: "grove" })).toEqual({
229 light: "grove",
230 dark: "grove",
231 });
232 expect(
233 resolveMobileThemeIds({ themeId: "grove", lightThemeId: "iris", darkThemeId: "ocean" }),
234 ).toEqual({ light: "iris", dark: "ocean" });
235 expect(resolveMobileThemeIds({ themeId: "grove", lightThemeId: "missing" })).toEqual({
236 light: DEFAULT_MOBILE_THEME_ID,
237 dark: "grove",
238 });
239 });
240
241 it("changes either theme without switching the active appearance", () => {
242 const themeIds = { light: "t3-chat", dark: "grove" } as const;
243 expect(createMobileThemeSelectionPatch(themeIds, "light", "dark", "ocean")).toEqual({
244 lightThemeId: "t3-chat",
245 darkThemeId: "ocean",
246 themeId: "t3-chat",
247 });
248 expect(createMobileThemeSelectionPatch(themeIds, "light", "light", "iris")).toEqual({
249 lightThemeId: "iris",
250 darkThemeId: "grove",
251 themeId: "iris",
252 });
253 });
254
255 it("converts OKLCH colors to React Native sRGB ColorValues", () => {
256 expect(themeColorToNativeColor("oklch(1 0 0)")).toBe("#ffffff");
257 expect(themeColorToNativeColor("oklch(0 0 0)")).toBe("#000000");
258 expect(themeColorToNativeColor("#123456")).toBe("#123456");
259 });
260
261 it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => {
262 for (const themeId of BUILT_IN_THEME_IDS) {
263 for (const appearance of ["light", "dark"] as const) {
264 const variables = getMobileThemeVariables(themeId, appearance);
265 expect(
266 contrastRatio(variables["--color-placeholder"], variables["--color-input"]),
267 ).toBeGreaterThanOrEqual(4.5);
268 }
269 }
270
271 for (const themeId of BUILT_IN_THEME_IDS) {
272 for (const appearance of ["light", "dark"] as const) {
273 const variables = getMobileThemeVariables(themeId, appearance);
274 expect(
275 contrastRatio(
276 variables["--color-user-bubble-foreground"],
277 variables["--color-user-bubble"],
278 ),
279 ).toBeGreaterThanOrEqual(4.5);
280 expect(
281 contrastRatio(
282 variables["--color-user-bubble-skill-foreground"],
283 variables["--color-user-bubble"],
284 ),
285 ).toBeGreaterThanOrEqual(4.5);
286 expect(variables["--color-user-bubble-skill-foreground"]).not.toBe(
287 variables["--color-user-bubble-foreground"],
288 );
289 const fenceSurface = compositeOver(
290 variables["--color-md-user-fence-bg"],
291 variables["--color-user-bubble"],
292 );
293 expect(fenceSurface).not.toBe(variables["--color-user-bubble"]);
294 expect(
295 contrastRatio(variables["--color-md-user-fence-text"], fenceSurface),
296 ).toBeGreaterThanOrEqual(4.5);
297 }
298 }
299 });
300
301 it("keeps the default user bubble readable in both appearances", () => {
302 for (const appearance of ["light", "dark"] as const) {
303 const variables = readDefaultMobileThemeVariables(appearance);
304 const bubble = variables["--color-user-bubble"];
305 expect(
306 contrastRatio(variables["--color-user-bubble-foreground"], bubble),
307 ).toBeGreaterThanOrEqual(4.5);
308 expect(
309 contrastRatio(variables["--color-user-bubble-skill-foreground"], bubble),
310 ).toBeGreaterThanOrEqual(4.5);
311 expect(variables["--color-user-bubble-skill-foreground"]).not.toBe(
312 variables["--color-user-bubble-foreground"],
313 );
314 const fenceSurface = compositeOver(variables["--color-md-user-fence-bg"], bubble);
315 expect(fenceSurface).not.toBe(bubble);
316 expect(
317 contrastRatio(variables["--color-md-user-fence-text"], fenceSurface),
318 ).toBeGreaterThanOrEqual(4.5);
319 const codeSurface = compositeOver(variables["--color-md-user-code-bg"], bubble);
320 expect(
321 contrastRatio(variables["--color-md-user-code-text"], codeSurface),
322 ).toBeGreaterThanOrEqual(4.5);
323 }
324 });
325});