Unified · Split
| @@ -22,19 +22,9 @@ · 21 unmodified lines | |||
| 22 | const isHexColor = (value) => | 22 | const isHexColor = (value) => |
| 23 | typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value.trim()); | 23 | typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value.trim()); |
| 24 | if (isHexColor(colors?.accentColor)) { | 24 | if (isHexColor(colors?.accentColor)) { |
| 25 | const accentColor = colors.accentColor.trim().toLowerCase(); | ||
| 26 | const channels = [1, 3, 5].map((offset) => | ||
| 27 | Number.parseInt(accentColor.slice(offset, offset + 2), 16), | ||
| 28 | ); | ||
| 29 | const [red, green, blue] = channels.map((channel) => { | ||
| 30 | const value = channel / 255; | ||
| 31 | return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; | ||
| 32 | }); | ||
| 33 | const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; | ||
| 34 | document.documentElement.style.setProperty("--theme-accent-seed", accentColor); | ||
| 35 | document.documentElement.style.setProperty( | 25 | document.documentElement.style.setProperty( |
| 36 | "--theme-accent-foreground", | 26 | "--theme-accent-seed", |
| 37 | luminance >= 0.179 ? "#000000" : "#ffffff", | 27 | colors.accentColor.trim().toLowerCase(), |
| 38 | ); | 28 | ); |
| 39 | } | 29 | } |
| 40 | if (isHexColor(colors?.neutralColor)) { | 30 | if (isHexColor(colors?.neutralColor)) { |
| @@ -86,17 +86,8 @@ describe("theme colors", () => { · 85 unmodified lines | |||
| 86 | applyThemeColors({ accentColor: "#16a34a", neutralColor: "#78716c", contrast: 50 }); | 86 | applyThemeColors({ accentColor: "#16a34a", neutralColor: "#78716c", contrast: 50 }); |
| 87 | 87 | ||
| 88 | expect(setProperty).toHaveBeenCalledWith("--theme-accent-seed", "#16a34a"); | 88 | expect(setProperty).toHaveBeenCalledWith("--theme-accent-seed", "#16a34a"); |
| 89 | expect(setProperty).toHaveBeenCalledWith("--theme-accent-foreground", "#000000"); | ||
| 90 | expect(setProperty).toHaveBeenCalledWith("--theme-neutral-seed", "#78716c"); | 89 | expect(setProperty).toHaveBeenCalledWith("--theme-neutral-seed", "#78716c"); |
| 91 | expect(setProperty).toHaveBeenCalledWith("--theme-accent-strength", "16%"); | 90 | expect(setProperty).toHaveBeenCalledWith("--theme-accent-strength", "16%"); |
| 92 | expect(setProperty).toHaveBeenCalledWith("--theme-border-strength", "16%"); | 91 | expect(setProperty).toHaveBeenCalledWith("--theme-border-strength", "16%"); |
| 93 | }); | 92 | }); |
| 94 | |||
| 95 | it("chooses a readable foreground for light and dark accents", async () => { | ||
| 96 | vi.stubGlobal("window", createWindow()); | ||
| 97 | const { getThemeAccentForeground } = await import("./useThemeColors"); | ||
| 98 | |||
| 99 | expect(getThemeAccentForeground("#facc15")).toBe("#000000"); | ||
| 100 | expect(getThemeAccentForeground("#1d4ed8")).toBe("#ffffff"); | ||
| 101 | }); | ||
| 102 | }); | 93 | }); |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { useCallback, useSyncExternalStore } from "react"; | 1 | import { useCallback, useSyncExternalStore } from "react"; |
| 2 | import { syncBrowserChromeTheme } from "./useTheme"; | ||
| 2 | 3 | ||
| 3 | export type ThemeColors = { | 4 | export type ThemeColors = { |
| 4 | accentColor: string; | 5 | accentColor: string; |
| @@ -36,18 +37,6 @@ export function normalizeThemeColors(value: unknown): ThemeColors { · 31 unmodified lines | |||
| 36 | }; | 37 | }; |
| 37 | } | 38 | } |
| 38 | 39 | ||
| 39 | export function getThemeAccentForeground(accentColor: string): "#000000" | "#ffffff" { | ||
| 40 | const channels = [1, 3, 5].map((offset) => | ||
| 41 | Number.parseInt(accentColor.slice(offset, offset + 2), 16), | ||
| 42 | ); | ||
| 43 | const [red = 0, green = 0, blue = 0] = channels.map((channel) => { | ||
| 44 | const value = channel / 255; | ||
| 45 | return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; | ||
| 46 | }); | ||
| 47 | const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; | ||
| 48 | return luminance >= 0.179 ? "#000000" : "#ffffff"; | ||
| 49 | } | ||
| 50 | |||
| 51 | function getThemeContrastProperties(contrast: number): Record<string, string> { | 40 | function getThemeContrastProperties(contrast: number): Record<string, string> { |
| 52 | return { | 41 | return { |
| 53 | "--theme-background-strength": `${4 + contrast * 0.12}%`, | 42 | "--theme-background-strength": `${4 + contrast * 0.12}%`, |
| @@ -72,11 +61,11 @@ export function applyThemeColors(colors: ThemeColors): void { · 18 unmodified lines | |||
| 72 | if (typeof document === "undefined") return; | 61 | if (typeof document === "undefined") return; |
| 73 | const rootStyle = document.documentElement.style; | 62 | const rootStyle = document.documentElement.style; |
| 74 | rootStyle.setProperty("--theme-accent-seed", colors.accentColor); | 63 | rootStyle.setProperty("--theme-accent-seed", colors.accentColor); |
| 75 | rootStyle.setProperty("--theme-accent-foreground", getThemeAccentForeground(colors.accentColor)); | ||
| 76 | rootStyle.setProperty("--theme-neutral-seed", colors.neutralColor); | 64 | rootStyle.setProperty("--theme-neutral-seed", colors.neutralColor); |
| 77 | for (const [property, value] of Object.entries(getThemeContrastProperties(colors.contrast))) { | 65 | for (const [property, value] of Object.entries(getThemeContrastProperties(colors.contrast))) { |
| 78 | rootStyle.setProperty(property, value); | 66 | rootStyle.setProperty(property, value); |
| 79 | } | 67 | } |
| 68 | syncBrowserChromeTheme(); | ||
| 80 | } | 69 | } |
| 81 | 70 | ||
| 82 | function emitChange(): void { | 71 | function emitChange(): void { |
| @@ -854,7 +854,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil · 853 unmodified lines | |||
| 854 | color-scheme: light; | 854 | color-scheme: light; |
| 855 | --radius: 0.625rem; | 855 | --radius: 0.625rem; |
| 856 | --theme-accent-seed: #3b5bdb; | 856 | --theme-accent-seed: #3b5bdb; |
| 857 | --theme-accent-foreground: #ffffff; | ||
| 858 | --theme-neutral-seed: #737373; | 857 | --theme-neutral-seed: #737373; |
| 859 | --theme-background-strength: 10%; | 858 | --theme-background-strength: 10%; |
| 860 | --theme-surface-strength: 14%; | 859 | --theme-surface-strength: 14%; |
| @@ -873,8 +872,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil · 12 unmodified lines | |||
| 873 | --card-foreground: var(--foreground); | 872 | --card-foreground: var(--foreground); |
| 874 | --popover: var(--card); | 873 | --popover: var(--card); |
| 875 | --popover-foreground: var(--foreground); | 874 | --popover-foreground: var(--foreground); |
| 876 | --primary: var(--theme-accent-seed); | 875 | /* Keep text, rings, and translucent primary controls readable for any seed. |
| 877 | --primary-foreground: var(--theme-accent-foreground); | 876 | Raw seed color still drives the more expressive accent surfaces below. */ |
| 877 | --primary: color-mix(in oklch, var(--theme-accent-seed) 46%, var(--color-black)); | ||
| 878 | --primary-foreground: var(--color-white); | ||
| 878 | --secondary: color-mix( | 879 | --secondary: color-mix( |
| 879 | in srgb, | 880 | in srgb, |
| 880 | var(--theme-neutral-seed) var(--theme-control-strength), | 881 | var(--theme-neutral-seed) var(--theme-control-strength), |
| @@ -932,8 +933,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil · 51 unmodified lines | |||
| 932 | var(--background) | 933 | var(--background) |
| 933 | ); | 934 | ); |
| 934 | --popover-foreground: var(--foreground); | 935 | --popover-foreground: var(--foreground); |
| 935 | --primary: var(--theme-accent-seed); | 936 | --primary: color-mix(in oklch, var(--theme-accent-seed) 40%, var(--color-white)); |
| 936 | --primary-foreground: var(--theme-accent-foreground); | 937 | --primary-foreground: var(--color-black); |
| 937 | --secondary: color-mix( | 938 | --secondary: color-mix( |
| 938 | in srgb, | 939 | in srgb, |
| 939 | var(--theme-neutral-seed) var(--theme-surface-strength), | 940 | var(--theme-neutral-seed) var(--theme-surface-strength), |