| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { |
| 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"; |
| 11 | import { |
| 12 | readDefaultMobileThemeVariables, |
| 13 | readGeneratedMobileThemeVariables, |
| 14 | } from "./mobileTheme.test-support"; |
| 15 | import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; |
| 16 | |
| 17 | import { |
| 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 | |
| 32 | function 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 | |
| 41 | function 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 | |
| 50 | function 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 | |
| 63 | describe("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 | |
| 505 | describe("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 | }); |