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 createMobileThemePairPatch,
19 createMobileThemeSelectionPatch,
20 createMobileThemeVariables,
21 DEFAULT_MOBILE_THEME_ID,
22 flattenThemeColor,
23 getMobileThemePreviewColors,
24 getMobileThemeVariables,
25 normalizeMobileThemeId,
26 normalizeMobileThemeMode,
27 resolveMobileThemeIds,
28 themeColorWithAlpha,
29 themeColorToNativeColor,
30} from "./mobileTheme";
31
32function relativeLuminance(hex: string): number {
33 const channels = hex
34 .slice(1)
35 .match(/.{2}/g)!
36 .map((channel) => Number.parseInt(channel, 16) / 255)
37 .map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
38 return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
39}
40
41function contrastRatio(first: string, second: string): number {
42 const firstLuminance = relativeLuminance(first);
43 const secondLuminance = relativeLuminance(second);
44 return (
45 (Math.max(firstLuminance, secondLuminance) + 0.05) /
46 (Math.min(firstLuminance, secondLuminance) + 0.05)
47 );
48}
49
50function compositeOver(overlay: string, background: string): string {
51 const overlayMatch = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(overlay)!;
52 const backgroundChannels = background
53 .slice(1)
54 .match(/.{2}/g)!
55 .map((channel) => Number.parseInt(channel, 16));
56 const alpha = Number(overlayMatch[4]);
57 const channels = [1, 2, 3].map((index) =>
58 Math.round(Number(overlayMatch[index]) * alpha + backgroundChannels[index - 1]! * (1 - alpha)),
59 );
60 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
61}
62
63describe("mobile themes", () => {
64 it("declares every runtime theme variable in the static stylesheet", () => {
65 const generatedVariables = createMobileThemeVariables(T3_CHAT_THEME.colors, "light");
66 expect(Object.keys(readDefaultMobileThemeVariables("light")).sort()).toEqual(
67 Object.keys(generatedVariables).sort(),
68 );
69 expect(Object.keys(readDefaultMobileThemeVariables("dark")).sort()).toEqual(
70 Object.keys(generatedVariables).sort(),
71 );
72 });
73
74 it("shares all built-in desktop palettes", () => {
75 expect(BUILT_IN_THEMES.map((theme) => theme.id)).toEqual(BUILT_IN_THEME_IDS);
76 for (const themeId of BUILT_IN_THEME_IDS) {
77 expect(getMobileThemeVariables(themeId, "light")["--color-screen"]).toMatch(/^#/);
78 expect(getMobileThemeVariables(themeId, "dark")["--color-screen"]).toMatch(/^#/);
79 }
80 });
81
82 it.each(MOBILE_THEME_IDS)("uses the web color roles for %s in both appearances", (themeId) => {
83 for (const appearance of ["light", "dark"] as const) {
84 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId);
85 const colors = theme
86 ? getThemeColorsForAppearance(theme, appearance)!
87 : appearance === "dark"
88 ? T3_CODE_DARK_THEME_COLORS
89 : T3_CODE_LIGHT_THEME_COLORS;
90 const variables =
91 themeId === DEFAULT_MOBILE_THEME_ID
92 ? readDefaultMobileThemeVariables(appearance)
93 : getMobileThemeVariables(themeId, appearance);
94 expect(variables["--color-screen"]).toBe(themeColorToNativeColor(colors.canvas));
95 expect(variables["--color-thread-canvas"]).toBe(variables["--color-screen"]);
96 expect(variables["--color-drawer"]).toBe(themeColorToNativeColor(colors.sidebar));
97 expect(variables["--color-thread-hover"]).toBe(
98 themeColorToNativeColor(colors.sidebarRowHover),
99 );
100 expect(variables["--color-card"]).toBe(themeColorToNativeColor(colors.surface));
101 expect(variables["--color-composer-surface"]).toBe(
102 themeColorWithAlpha(variables["--color-grouped-card"], appearance === "dark" ? 0.9 : 0.94),
103 );
104 expect(variables["--color-thread-selected"]).toBe(
105 themeColorToNativeColor(colors.sidebarRowActive),
106 );
107 expect(variables["--color-thread-selected-foreground"]).toBe(
108 themeColorToNativeColor(colors.sidebarForeground),
109 );
110 expect(variables["--color-primary"]).toBe(themeColorToNativeColor(colors.messageAction));
111 if (themeId !== DEFAULT_MOBILE_THEME_ID) {
112 expect(variables["--color-user-bubble"]).toBe(
113 themeColorToNativeColor(colors.messageSurface),
114 );
115 }
116 expect(
117 contrastRatio(variables["--color-foreground"], variables["--color-screen"]),
118 ).toBeGreaterThanOrEqual(4.5);
119 for (const [foreground, surface] of [
120 ["--color-drawer-foreground", "--color-drawer"],
121 ["--color-drawer-foreground-muted", "--color-drawer"],
122 ["--color-drawer-foreground", "--color-thread-hover"],
123 ["--color-drawer-foreground-muted", "--color-thread-hover"],
124 ["--color-thread-selected-foreground-muted", "--color-thread-selected"],
125 ["--color-primary-foreground", "--color-primary"],
126 ["--color-primary-text", "--color-screen"],
127 ["--color-primary-text", "--color-card"],
128 ["--color-primary-text", "--color-card-alt"],
129 ["--color-primary-text", "--color-sheet-solid"],
130 ["--color-foreground", "--color-sheet-solid"],
131 ["--color-foreground-muted", "--color-sheet-solid"],
132 ["--color-primary-text", "--color-grouped-card"],
133 ["--color-foreground", "--color-grouped-card"],
134 ["--color-foreground-muted", "--color-grouped-card"],
135 ["--color-placeholder", "--color-grouped-card"],
136 ["--color-secondary-foreground", "--color-secondary"],
137 ["--color-user-bubble-foreground", "--color-user-bubble"],
138 ["--color-warning-foreground", "--color-warning"],
139 ["--color-danger-foreground", "--color-danger"],
140 ["--color-md-body", "--color-screen"],
141 ["--color-md-strong", "--color-screen"],
142 ["--color-md-link", "--color-screen"],
143 ["--color-md-code-text", "--color-md-code-bg"],
144 ] as const) {
145 expect(
146 contrastRatio(variables[foreground], variables[surface]),
147 `${appearance}: ${foreground} on ${surface}`,
148 ).toBeGreaterThanOrEqual(4.5);
149 }
150 expect(
151 contrastRatio(
152 variables["--color-thread-selected-foreground"],
153 variables["--color-thread-selected"],
154 ),
155 ).toBeGreaterThanOrEqual(4.5);
156 }
157 });
158
159 it.each(MOBILE_THEME_IDS)("keeps %s sidebar text readable on Android", (themeId) => {
160 for (const appearance of ["light", "dark"] as const) {
161 const variables = getMobileThemeRuntimeVariables(themeId, appearance, "android");
162 for (const foreground of [
163 "--color-drawer-foreground",
164 "--color-drawer-foreground-muted",
165 ] as const) {
166 expect(
167 contrastRatio(variables[foreground], variables["--color-drawer"]),
168 `${appearance}: ${foreground}`,
169 ).toBeGreaterThanOrEqual(4.5);
170 }
171 }
172 });
173
174 it("applies palette overrides on top of the selected built-in theme", () => {
175 const variables = getMobileThemeVariables("ocean", "dark", {
176 "--color-primary": "#123456",
177 });
178
179 expect(variables["--color-primary"]).toBe("#123456");
180 expect(variables["--color-screen"]).toMatch(/^#/);
181 });
182
183 it.each(MOBILE_THEME_IDS)("separates %s filled groups from their sheets", (themeId) => {
184 for (const appearance of ["light", "dark"] as const) {
185 const generated = readGeneratedMobileThemeVariables(
186 themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`,
187 );
188 expect(generated).toEqual(getMobileThemeVariables(themeId, appearance));
189 for (const platform of ["ios", "android"]) {
190 const variables = getMobileThemeRuntimeVariables(themeId, appearance, platform);
191 const sheet = variables["--color-sheet-solid"];
192 const group = variables["--color-grouped-card"];
193 expect(
194 contrastRatio(group, sheet),
195 `${platform} ${appearance}: grouped surface on sheet`,
196 ).toBeGreaterThanOrEqual(1.025);
197 for (const role of [
198 "--color-foreground",
199 "--color-foreground-secondary",
200 "--color-foreground-muted",
201 "--color-placeholder",
202 "--color-primary-text",
203 ] as const) {
204 expect(
205 contrastRatio(variables[role], group),
206 `${platform} ${appearance}: ${role} on grouped surface`,
207 ).toBeGreaterThanOrEqual(4.5);
208 }
209 if (platform === "android") {
210 const header = flattenThemeColor(variables["--color-header"], sheet);
211 for (const role of ["--color-header-foreground", "--color-foreground-muted"] as const) {
212 expect(
213 contrastRatio(variables[role], header),
214 `${appearance}: ${role} on Android header`,
215 ).toBeGreaterThanOrEqual(4.5);
216 }
217 }
218 expect(
219 contrastRatio(variables["--color-foreground-muted"], variables["--color-secondary"]),
220 `${platform} ${appearance}: muted label on secondary surface`,
221 ).toBeGreaterThanOrEqual(4.5);
222 }
223 }
224 });
225
226 it.each(["light", "dark"] as const)(
227 "separates default settings groups from their %s background",
228 (appearance) => {
229 const variables = getMobileThemeVariables("t3-code", appearance);
230 expect(
231 contrastRatio(variables["--color-grouped-card"], variables["--color-sheet-solid"]),
232 ).toBeGreaterThanOrEqual(1.06);
233 expect(variables["--color-grouped-card"]).not.toBe(variables["--color-card"]);
234 for (const platform of ["ios", "android"]) {
235 const runtime = getMobileThemeRuntimeVariables("t3-code", appearance, platform);
236 const sidebar = flattenThemeColor(runtime["--color-drawer"], runtime["--color-screen"]);
237 const chrome = flattenThemeColor(
238 runtime[platform === "android" ? "--color-header" : "--color-drawer"],
239 runtime["--color-screen"],
240 );
241 if (platform === "ios") {
242 expect(relativeLuminance(sidebar)).toBeLessThan(
243 relativeLuminance(runtime["--color-thread-canvas"]),
244 );
245 }
246 expect(contrastRatio(chrome, runtime["--color-screen"])).toBeGreaterThanOrEqual(1.06);
247 const foregroundRoles =
248 platform === "android"
249 ? (["--color-header-foreground", "--color-foreground-muted"] as const)
250 : (["--color-drawer-foreground", "--color-drawer-foreground-muted"] as const);
251 for (const role of foregroundRoles) {
252 expect(contrastRatio(runtime[role], chrome)).toBeGreaterThanOrEqual(4.5);
253 }
254 }
255 },
256 );
257
258 it("keeps T3 Chat light grouped cards distinct from the sheet", () => {
259 const variables = getMobileThemeVariables("t3-chat", "light");
260 const group = variables["--color-grouped-card"];
261 expect(contrastRatio(group, variables["--color-sheet-solid"])).toBeGreaterThanOrEqual(1.1);
262 for (const role of ["--color-foreground", "--color-foreground-muted"] as const) {
263 expect(contrastRatio(variables[role], group)).toBeGreaterThanOrEqual(4.5);
264 }
265 });
266
267 it.each(MOBILE_THEME_IDS)("keeps %s prominent actions readable and themed", (themeId) => {
268 for (const appearance of ["light", "dark"] as const) {
269 const variables = getMobileThemeVariables(themeId, appearance);
270 const fill = variables["--color-prominent-action"];
271 expect(
272 contrastRatio(variables["--color-prominent-action-foreground"], fill),
273 ).toBeGreaterThanOrEqual(4.5);
274 expect(fill).toBe(
275 variables[
276 themeId === "t3-chat" && appearance === "dark"
277 ? "--color-primary-foreground"
278 : themeId === DEFAULT_MOBILE_THEME_ID || themeId === "t3-chat"
279 ? "--color-foreground"
280 : "--color-primary"
281 ],
282 );
283 }
284 });
285
286 it.each(["grove", "ocean", "ember", "iris"] as const)(
287 "keeps %s grouped cards and floating controls visibly tinted",
288 (themeId) => {
289 const saturation = (hex: string) => {
290 const channels = hex
291 .slice(1)
292 .match(/.{2}/g)!
293 .map((value) => parseInt(value, 16));
294 return (Math.max(...channels) - Math.min(...channels)) / Math.max(...channels);
295 };
296 for (const appearance of ["light", "dark"] as const) {
297 const variables = getMobileThemeVariables(themeId, appearance);
298 const sheet = variables["--color-sheet-solid"];
299 const group = variables["--color-grouped-card"];
300 expect(contrastRatio(group, sheet)).toBeGreaterThanOrEqual(1.06);
301 for (const role of ["--color-composer-surface", "--color-glass-fallback"] as const) {
302 const surface = flattenThemeColor(variables[role], sheet);
303 expect(contrastRatio(surface, sheet)).toBeGreaterThanOrEqual(1.06);
304 expect(contrastRatio(variables["--color-placeholder"], surface)).toBeGreaterThanOrEqual(
305 4.5,
306 );
307 if (appearance === "dark") {
308 expect(saturation(surface)).toBeGreaterThanOrEqual(saturation(sheet));
309 }
310 }
311 if (appearance === "dark") {
312 expect(saturation(group)).toBeGreaterThanOrEqual(saturation(sheet));
313 }
314 }
315 },
316 );
317
318 it.each(["light", "dark"] as const)(
319 "slightly strengthens default %s messages and separates fallback materials",
320 (appearance) => {
321 const variables = getMobileThemeVariables("t3-code", appearance);
322 const desktop =
323 appearance === "dark" ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS;
324 const bubbleContrast = contrastRatio(
325 variables["--color-user-bubble"],
326 variables["--color-screen"],
327 );
328 expect(bubbleContrast).toBeGreaterThan(contrastRatio(desktop.messageSurface, desktop.canvas));
329 expect(bubbleContrast).toBeLessThan(1.2);
330 for (const role of ["--color-composer-surface", "--color-glass-fallback"] as const) {
331 const surface = flattenThemeColor(variables[role], variables["--color-screen"]);
332 expect(contrastRatio(surface, variables["--color-screen"])).toBeGreaterThanOrEqual(1.06);
333 for (const foreground of [
334 "--color-foreground",
335 "--color-placeholder",
336 "--color-primary-text",
337 ] as const) {
338 expect(contrastRatio(variables[foreground], surface)).toBeGreaterThanOrEqual(4.5);
339 }
340 }
341 },
342 );
343
344 it("uses the same preview roles and standard artwork as desktop", () => {
345 expect(getMobileThemePreviewColors(DEFAULT_MOBILE_THEME_ID, "light")).toEqual({
346 canvas: "#fcfcfc",
347 accent: "#f4f4f5",
348 messageAction: "#4f46e5",
349 });
350 const desktopOcean = BUILT_IN_THEMES.find((theme) => theme.id === "ocean")!;
351 expect(getMobileThemePreviewColors("ocean", "light")).toEqual({
352 canvas: themeColorToNativeColor(desktopOcean.colors.canvas),
353 accent: themeColorToNativeColor(desktopOcean.colors.accent),
354 messageAction: themeColorToNativeColor(desktopOcean.colors.messageAction),
355 });
356 });
357
358 it("normalizes persisted theme preferences", () => {
359 expect(normalizeMobileThemeId("ocean")).toBe("ocean");
360 expect(normalizeMobileThemeId("missing-theme")).toBe(DEFAULT_MOBILE_THEME_ID);
361 expect(normalizeMobileThemeMode("dark")).toBe("dark");
362 expect(normalizeMobileThemeMode("sepia")).toBe("system");
363 });
364
365 it("migrates one theme choice to both appearances and preserves independent choices", () => {
366 expect(resolveMobileThemeIds({ themeId: "grove" })).toEqual({
367 light: "grove",
368 dark: "grove",
369 });
370 expect(
371 resolveMobileThemeIds({ themeId: "grove", lightThemeId: "iris", darkThemeId: "ocean" }),
372 ).toEqual({ light: "iris", dark: "ocean" });
373 expect(resolveMobileThemeIds({ themeId: "grove", lightThemeId: "missing" })).toEqual({
374 light: DEFAULT_MOBILE_THEME_ID,
375 dark: "grove",
376 });
377 });
378
379 it("changes either theme without switching the active appearance", () => {
380 const themeIds = { light: "t3-chat", dark: "grove" } as const;
381 expect(createMobileThemeSelectionPatch(themeIds, "light", "dark", "ocean")).toEqual({
382 lightThemeId: "t3-chat",
383 darkThemeId: "ocean",
384 themeId: "t3-chat",
385 });
386 expect(createMobileThemeSelectionPatch(themeIds, "light", "light", "iris")).toEqual({
387 lightThemeId: "iris",
388 darkThemeId: "grove",
389 themeId: "iris",
390 });
391 });
392
393 it("changes both appearance themes from the card action", () => {
394 expect(createMobileThemePairPatch("ember")).toEqual({
395 lightThemeId: "ember",
396 darkThemeId: "ember",
397 themeId: "ember",
398 });
399 });
400
401 it("converts OKLCH colors to React Native sRGB ColorValues", () => {
402 expect(themeColorToNativeColor("oklch(1 0 0)")).toBe("#ffffff");
403 expect(themeColorToNativeColor("oklch(0 0 0)")).toBe("#000000");
404 expect(themeColorToNativeColor("#123456")).toBe("#123456");
405 });
406
407 it("changes native palette color opacity for fades", () => {
408 expect(themeColorWithAlpha("#123456", 0)).toBe("rgba(18, 52, 86, 0)");
409 expect(themeColorWithAlpha("rgba(18, 52, 86, 0.98)", 0)).toBe("rgba(18, 52, 86, 0)");
410 });
411
412 it("maps semantic palette roles onto every mobile color variable", () => {
413 const variables = createMobileThemeVariables(T3_CHAT_THEME.colors, "light");
414 expect(variables["--color-sheet-solid"]).toBe(
415 themeColorToNativeColor(T3_CHAT_THEME.colors.chrome),
416 );
417 expect(variables["--color-warning"]).toBe(
418 themeColorToNativeColor(T3_CHAT_THEME.colors.warningSurface),
419 );
420 expect(variables["--color-warning-foreground"]).toBe(
421 themeColorToNativeColor(T3_CHAT_THEME.colors.warningForeground),
422 );
423 expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]);
424 expect(variables["--color-primary-shadow"]).toBe("#000000");
425 expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)");
426 expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)");
427 expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/);
428 });
429
430 it("keeps every built-in shadow and backdrop black-based in dark mode", () => {
431 for (const themeId of BUILT_IN_THEME_IDS) {
432 const variables = getMobileThemeVariables(themeId, "dark");
433 expect(variables["--color-primary-shadow"]).toBe("#000000");
434 expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)");
435 expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)");
436 }
437 });
438
439 it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => {
440 for (const themeId of BUILT_IN_THEME_IDS) {
441 for (const appearance of ["light", "dark"] as const) {
442 const variables = getMobileThemeVariables(themeId, appearance);
443 expect(
444 contrastRatio(variables["--color-placeholder"], variables["--color-input"]),
445 ).toBeGreaterThanOrEqual(4.5);
446 }
447 }
448
449 for (const themeId of BUILT_IN_THEME_IDS) {
450 for (const appearance of ["light", "dark"] as const) {
451 const variables = getMobileThemeVariables(themeId, appearance);
452 expect(
453 contrastRatio(
454 variables["--color-user-bubble-foreground"],
455 variables["--color-user-bubble"],
456 ),
457 ).toBeGreaterThanOrEqual(4.5);
458 expect(
459 contrastRatio(
460 variables["--color-user-bubble-skill-foreground"],
461 variables["--color-user-bubble"],
462 ),
463 ).toBeGreaterThanOrEqual(4.5);
464 expect(variables["--color-user-bubble-skill-foreground"]).not.toBe(
465 variables["--color-user-bubble-foreground"],
466 );
467 const fenceSurface = compositeOver(
468 variables["--color-md-user-fence-bg"],
469 variables["--color-user-bubble"],
470 );
471 expect(fenceSurface).not.toBe(variables["--color-user-bubble"]);
472 expect(
473 contrastRatio(variables["--color-md-user-fence-text"], fenceSurface),
474 ).toBeGreaterThanOrEqual(4.5);
475 }
476 }
477 });
478
479 it("keeps the default user bubble readable in both appearances", () => {
480 for (const appearance of ["light", "dark"] as const) {
481 const variables = readDefaultMobileThemeVariables(appearance);
482 const bubble = variables["--color-user-bubble"];
483 expect(
484 contrastRatio(variables["--color-user-bubble-foreground"], bubble),
485 ).toBeGreaterThanOrEqual(4.5);
486 expect(
487 contrastRatio(variables["--color-user-bubble-skill-foreground"], bubble),
488 ).toBeGreaterThanOrEqual(4.5);
489 expect(variables["--color-user-bubble-skill-foreground"]).not.toBe(
490 variables["--color-user-bubble-foreground"],
491 );
492 const fenceSurface = compositeOver(variables["--color-md-user-fence-bg"], bubble);
493 expect(fenceSurface).not.toBe(bubble);
494 expect(
495 contrastRatio(variables["--color-md-user-fence-text"], fenceSurface),
496 ).toBeGreaterThanOrEqual(4.5);
497 const codeSurface = compositeOver(variables["--color-md-user-code-bg"], bubble);
498 expect(
499 contrastRatio(variables["--color-md-user-code-text"], codeSurface),
500 ).toBeGreaterThanOrEqual(4.5);
501 }
502 });
503});
504
505describe("flattenThemeColor", () => {
506 it("composites a translucent border over its surface", async () => {
507 const { flattenThemeColor } = await import("./mobileTheme");
508 // `--color-border` in the dark theme, over the surface a chip sits on. Native chip drawing
509 // parses opaque hex only, so this has to resolve before it crosses the bridge.
510 expect(flattenThemeColor("rgba(255, 255, 255, 0.06)", "#171717")).toBe("#252525");
511 expect(flattenThemeColor("rgba(0, 0, 0, 0.08)", "#ffffff")).toBe("#ebebeb");
512 });
513
514 it("leaves an already opaque colour alone", async () => {
515 const { flattenThemeColor } = await import("./mobileTheme");
516 expect(flattenThemeColor("#171717", "#ffffff")).toBe("#171717");
517 });
518
519 it("treats a colour with no alpha as fully opaque", async () => {
520 const { flattenThemeColor } = await import("./mobileTheme");
521 expect(flattenThemeColor("rgb(255, 0, 0)", "#000000")).toBe("#ff0000");
522 });
523});