import { describe, expect, it } from "vite-plus/test";import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; import { createMobileThemePairPatch, createMobileThemeSelectionPatch, createMobileThemeVariables, DEFAULT_MOBILE_THEME_ID, getMobileThemePreviewColors, getMobileThemeVariables, normalizeMobileThemeId, normalizeMobileThemeMode, resolveMobileThemeIds, themeColorWithAlpha, themeColorToNativeColor,} from "./mobileTheme"; function relativeLuminance(hex: string): number { const channels = hex .slice(1) .match(/.{2}/g)! .map((channel) => Number.parseInt(channel, 16) / 255) .map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)); return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;} function contrastRatio(first: string, second: string): number { const firstLuminance = relativeLuminance(first); const secondLuminance = relativeLuminance(second); return ( (Math.max(firstLuminance, secondLuminance) + 0.05) / (Math.min(firstLuminance, secondLuminance) + 0.05) );} function compositeOver(overlay: string, background: string): string { const overlayMatch = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(overlay)!; const backgroundChannels = background .slice(1) .match(/.{2}/g)! .map((channel) => Number.parseInt(channel, 16)); const alpha = Number(overlayMatch[4]); const channels = [1, 2, 3].map((index) => Math.round(Number(overlayMatch[index]) * alpha + backgroundChannels[index - 1]! * (1 - alpha)), ); return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;} describe("mobile themes", () => { it("declares every runtime theme variable in the static stylesheet", () => { const generatedVariables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); expect(Object.keys(readDefaultMobileThemeVariables("light")).sort()).toEqual( Object.keys(generatedVariables).sort(), ); expect(Object.keys(readDefaultMobileThemeVariables("dark")).sort()).toEqual( Object.keys(generatedVariables).sort(), ); }); it("shares all built-in desktop palettes", () => { expect(BUILT_IN_THEMES.map((theme) => theme.id)).toEqual(BUILT_IN_THEME_IDS); for (const themeId of BUILT_IN_THEME_IDS) { expect(getMobileThemeVariables(themeId, "light")["--color-screen"]).toMatch(/^#/); expect(getMobileThemeVariables(themeId, "dark")["--color-screen"]).toMatch(/^#/); } }); it("preserves the existing mobile palette as the default", () => { expect(readDefaultMobileThemeVariables("light")["--color-screen"]).toBe("#f2f2f7"); expect(readDefaultMobileThemeVariables("dark")["--color-screen"]).toBe("#0a0a0a"); expect(readDefaultMobileThemeVariables("light")["--color-user-bubble-skill-foreground"]).toBe( "#f0abfc", ); }); it("applies palette overrides on top of the selected built-in theme", () => { const variables = getMobileThemeVariables("ocean", "dark", { "--color-primary": "#123456", }); expect(variables["--color-primary"]).toBe("#123456"); expect(variables["--color-screen"]).toMatch(/^#/); }); it("uses the same preview roles and standard artwork as desktop", () => { expect(getMobileThemePreviewColors(DEFAULT_MOBILE_THEME_ID, "light")).toEqual({ canvas: "#fcfcfc", accent: "#f4f4f5", messageAction: "#4f46e5", }); const desktopOcean = BUILT_IN_THEMES.find((theme) => theme.id === "ocean")!; expect(getMobileThemePreviewColors("ocean", "light")).toEqual({ canvas: themeColorToNativeColor(desktopOcean.colors.canvas), accent: themeColorToNativeColor(desktopOcean.colors.accent), messageAction: themeColorToNativeColor(desktopOcean.colors.messageAction), }); }); it("normalizes persisted theme preferences", () => { expect(normalizeMobileThemeId("ocean")).toBe("ocean"); expect(normalizeMobileThemeId("missing-theme")).toBe(DEFAULT_MOBILE_THEME_ID); expect(normalizeMobileThemeMode("dark")).toBe("dark"); expect(normalizeMobileThemeMode("sepia")).toBe("system"); }); it("migrates one theme choice to both appearances and preserves independent choices", () => { expect(resolveMobileThemeIds({ themeId: "grove" })).toEqual({ light: "grove", dark: "grove", }); expect( resolveMobileThemeIds({ themeId: "grove", lightThemeId: "iris", darkThemeId: "ocean" }), ).toEqual({ light: "iris", dark: "ocean" }); expect(resolveMobileThemeIds({ themeId: "grove", lightThemeId: "missing" })).toEqual({ light: DEFAULT_MOBILE_THEME_ID, dark: "grove", }); }); it("changes either theme without switching the active appearance", () => { const themeIds = { light: "t3-chat", dark: "grove" } as const; expect(createMobileThemeSelectionPatch(themeIds, "light", "dark", "ocean")).toEqual({ lightThemeId: "t3-chat", darkThemeId: "ocean", themeId: "t3-chat", }); expect(createMobileThemeSelectionPatch(themeIds, "light", "light", "iris")).toEqual({ lightThemeId: "iris", darkThemeId: "grove", themeId: "iris", }); }); it("changes both appearance themes from the card action", () => { expect(createMobileThemePairPatch("ember")).toEqual({ lightThemeId: "ember", darkThemeId: "ember", themeId: "ember", }); }); it("converts OKLCH colors to React Native sRGB ColorValues", () => { expect(themeColorToNativeColor("oklch(1 0 0)")).toBe("#ffffff"); expect(themeColorToNativeColor("oklch(0 0 0)")).toBe("#000000"); expect(themeColorToNativeColor("#123456")).toBe("#123456"); }); it("changes native palette color opacity for fades", () => { expect(themeColorWithAlpha("#123456", 0)).toBe("rgba(18, 52, 86, 0)"); expect(themeColorWithAlpha("rgba(18, 52, 86, 0.98)", 0)).toBe("rgba(18, 52, 86, 0)"); }); it("maps semantic palette roles onto every mobile color variable", () => { const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); expect(Object.keys(variables)).toHaveLength(75); expect(variables["--color-sheet-solid"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), ); expect(variables["--color-warning"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningSurface), ); expect(variables["--color-warning-foreground"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningForeground), ); expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); expect(variables["--color-primary-shadow"]).toBe("#000000"); expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)"); expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/); }); it("keeps every built-in shadow and backdrop black-based in dark mode", () => { for (const themeId of BUILT_IN_THEME_IDS) { const variables = getMobileThemeVariables(themeId, "dark"); expect(variables["--color-primary-shadow"]).toBe("#000000"); expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)"); expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)"); } }); it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => { for (const themeId of BUILT_IN_THEME_IDS) { for (const appearance of ["light", "dark"] as const) { const variables = getMobileThemeVariables(themeId, appearance); expect( contrastRatio(variables["--color-placeholder"], variables["--color-input"]), ).toBeGreaterThanOrEqual(4.5); } } for (const themeId of BUILT_IN_THEME_IDS) { for (const appearance of ["light", "dark"] as const) { const variables = getMobileThemeVariables(themeId, appearance); expect( contrastRatio( variables["--color-user-bubble-foreground"], variables["--color-user-bubble"], ), ).toBeGreaterThanOrEqual(4.5); expect( contrastRatio( variables["--color-user-bubble-skill-foreground"], variables["--color-user-bubble"], ), ).toBeGreaterThanOrEqual(4.5); expect(variables["--color-user-bubble-skill-foreground"]).not.toBe( variables["--color-user-bubble-foreground"], ); const fenceSurface = compositeOver( variables["--color-md-user-fence-bg"], variables["--color-user-bubble"], ); expect(fenceSurface).not.toBe(variables["--color-user-bubble"]); expect( contrastRatio(variables["--color-md-user-fence-text"], fenceSurface), ).toBeGreaterThanOrEqual(4.5); } } });});
import { describe, expect, it } from "vite-plus/test";
import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support";
import {
createMobileThemePairPatch,
createMobileThemeSelectionPatch,
createMobileThemeVariables,
DEFAULT_MOBILE_THEME_ID,
getMobileThemePreviewColors,
getMobileThemeVariables,
normalizeMobileThemeId,
normalizeMobileThemeMode,
resolveMobileThemeIds,
themeColorWithAlpha,
themeColorToNativeColor,
} from "./mobileTheme";
function relativeLuminance(hex: string): number {
const channels = hex
.slice(1)
.match(/.{2}/g)!
.map((channel) => Number.parseInt(channel, 16) / 255)
.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
}
function contrastRatio(first: string, second: string): number {
const firstLuminance = relativeLuminance(first);
const secondLuminance = relativeLuminance(second);
return (
(Math.max(firstLuminance, secondLuminance) + 0.05) /
(Math.min(firstLuminance, secondLuminance) + 0.05)
);
}
function compositeOver(overlay: string, background: string): string {
const overlayMatch = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(overlay)!;
const backgroundChannels = background
.slice(1)
.match(/.{2}/g)!
.map((channel) => Number.parseInt(channel, 16));
const alpha = Number(overlayMatch[4]);
const channels = [1, 2, 3].map((index) =>
Math.round(Number(overlayMatch[index]) * alpha + backgroundChannels[index - 1]! * (1 - alpha)),
);
return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
}
describe("mobile themes", () => {
it("declares every runtime theme variable in the static stylesheet", () => {
const generatedVariables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light");
expect(Object.keys(readDefaultMobileThemeVariables("light")).sort()).toEqual(
Object.keys(generatedVariables).sort(),
);
expect(Object.keys(readDefaultMobileThemeVariables("dark")).sort()).toEqual(
Object.keys(generatedVariables).sort(),
);
});
it("shares all built-in desktop palettes", () => {
expect(BUILT_IN_THEMES.map((theme) => theme.id)).toEqual(BUILT_IN_THEME_IDS);
for (const themeId of BUILT_IN_THEME_IDS) {
expect(getMobileThemeVariables(themeId, "light")["--color-screen"]).toMatch(/^#/);
expect(getMobileThemeVariables(themeId, "dark")["--color-screen"]).toMatch(/^#/);
}
});
it("preserves the existing mobile palette as the default", () => {
expect(readDefaultMobileThemeVariables("light")["--color-screen"]).toBe("#f2f2f7");
expect(readDefaultMobileThemeVariables("dark")["--color-screen"]).toBe("#0a0a0a");
expect(readDefaultMobileThemeVariables("light")["--color-user-bubble-skill-foreground"]).toBe(
"#f0abfc",
);
});
it("applies palette overrides on top of the selected built-in theme", () => {
const variables = getMobileThemeVariables("ocean", "dark", {
"--color-primary": "#123456",
});
expect(variables["--color-primary"]).toBe("#123456");
expect(variables["--color-screen"]).toMatch(/^#/);
});
it("uses the same preview roles and standard artwork as desktop", () => {
expect(getMobileThemePreviewColors(DEFAULT_MOBILE_THEME_ID, "light")).toEqual({
canvas: "#fcfcfc",
accent: "#f4f4f5",
messageAction: "#4f46e5",
});
const desktopOcean = BUILT_IN_THEMES.find((theme) => theme.id === "ocean")!;
expect(getMobileThemePreviewColors("ocean", "light")).toEqual({
canvas: themeColorToNativeColor(desktopOcean.colors.canvas),
accent: themeColorToNativeColor(desktopOcean.colors.accent),
messageAction: themeColorToNativeColor(desktopOcean.colors.messageAction),
});
});
it("normalizes persisted theme preferences", () => {
expect(normalizeMobileThemeId("ocean")).toBe("ocean");
expect(normalizeMobileThemeId("missing-theme")).toBe(DEFAULT_MOBILE_THEME_ID);
expect(normalizeMobileThemeMode("dark")).toBe("dark");
expect(normalizeMobileThemeMode("sepia")).toBe("system");
});
it("migrates one theme choice to both appearances and preserves independent choices", () => {
expect(resolveMobileThemeIds({ themeId: "grove" })).toEqual({
light: "grove",
dark: "grove",
});
expect(
resolveMobileThemeIds({ themeId: "grove", lightThemeId: "iris", darkThemeId: "ocean" }),
).toEqual({ light: "iris", dark: "ocean" });
expect(resolveMobileThemeIds({ themeId: "grove", lightThemeId: "missing" })).toEqual({
light: DEFAULT_MOBILE_THEME_ID,
dark: "grove",
});
});
it("changes either theme without switching the active appearance", () => {
const themeIds = { light: "t3-chat", dark: "grove" } as const;
expect(createMobileThemeSelectionPatch(themeIds, "light", "dark", "ocean")).toEqual({
lightThemeId: "t3-chat",
darkThemeId: "ocean",
themeId: "t3-chat",
});
expect(createMobileThemeSelectionPatch(themeIds, "light", "light", "iris")).toEqual({
lightThemeId: "iris",
darkThemeId: "grove",
themeId: "iris",
});
});
it("changes both appearance themes from the card action", () => {
expect(createMobileThemePairPatch("ember")).toEqual({
lightThemeId: "ember",
darkThemeId: "ember",
themeId: "ember",
});
});
it("converts OKLCH colors to React Native sRGB ColorValues", () => {
expect(themeColorToNativeColor("oklch(1 0 0)")).toBe("#ffffff");
expect(themeColorToNativeColor("oklch(0 0 0)")).toBe("#000000");
expect(themeColorToNativeColor("#123456")).toBe("#123456");
});
it("changes native palette color opacity for fades", () => {
expect(themeColorWithAlpha("#123456", 0)).toBe("rgba(18, 52, 86, 0)");
expect(themeColorWithAlpha("rgba(18, 52, 86, 0.98)", 0)).toBe("rgba(18, 52, 86, 0)");
});
it("maps semantic palette roles onto every mobile color variable", () => {
const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light");
expect(Object.keys(variables)).toHaveLength(75);
expect(variables["--color-sheet-solid"]).toBe(
themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome),
);
expect(variables["--color-warning"]).toBe(
themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningSurface),
);
expect(variables["--color-warning-foreground"]).toBe(
themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningForeground),
);
expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]);
expect(variables["--color-primary-shadow"]).toBe("#000000");
expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)");
expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)");
expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/);
});
it("keeps every built-in shadow and backdrop black-based in dark mode", () => {
for (const themeId of BUILT_IN_THEME_IDS) {
const variables = getMobileThemeVariables(themeId, "dark");
expect(variables["--color-primary-shadow"]).toBe("#000000");
expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)");
expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)");
}
});
it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => {
for (const themeId of BUILT_IN_THEME_IDS) {
for (const appearance of ["light", "dark"] as const) {
const variables = getMobileThemeVariables(themeId, appearance);
expect(
contrastRatio(variables["--color-placeholder"], variables["--color-input"]),
).toBeGreaterThanOrEqual(4.5);
}
}
for (const themeId of BUILT_IN_THEME_IDS) {
for (const appearance of ["light", "dark"] as const) {
const variables = getMobileThemeVariables(themeId, appearance);
expect(
contrastRatio(
variables["--color-user-bubble-foreground"],
variables["--color-user-bubble"],
),
).toBeGreaterThanOrEqual(4.5);
expect(
contrastRatio(
variables["--color-user-bubble-skill-foreground"],
variables["--color-user-bubble"],
),
).toBeGreaterThanOrEqual(4.5);
expect(variables["--color-user-bubble-skill-foreground"]).not.toBe(
variables["--color-user-bubble-foreground"],
);
const fenceSurface = compositeOver(
variables["--color-md-user-fence-bg"],
variables["--color-user-bubble"],
);
expect(fenceSurface).not.toBe(variables["--color-user-bubble"]);
expect(
contrastRatio(variables["--color-md-user-fence-text"], fenceSurface),
).toBeGreaterThanOrEqual(4.5);
}
}
});
});