Skip to content

Commit 083fa4a

Browse files
authored
feat(web): use OKLCH for theme palettes (#6036)
1 parent 3517201 commit 083fa4a

10 files changed

Lines changed: 978 additions & 480 deletions

File tree

apps/web/index.html

Lines changed: 72 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -35,89 +35,89 @@
3535
// app colors before React mounts.
3636
const DEFAULT_THEME_PALETTES = {
3737
light: {
38-
background: "#fdf7fd",
39-
foreground: "#501854",
40-
accent: "#db2777",
41-
chrome: "#fdf7fd",
38+
background: "oklch(0.982446 0.010114 325.653)",
39+
foreground: "oklch(0.325698 0.116116 325.037)",
40+
accent: "oklch(0.591646 0.217985 0.584)",
41+
chrome: "oklch(0.982446 0.010114 325.653)",
4242
},
4343
dark: {
44-
background: "#1f1a24",
45-
foreground: "#f9f8fb",
46-
accent: "#a3004c",
47-
chrome: "#1f1a24",
44+
background: "oklch(0.22813 0.020366 307.469)",
45+
foreground: "oklch(0.980735 0.004092 301.426)",
46+
accent: "oklch(0.460685 0.185347 4.099)",
47+
chrome: "oklch(0.22813 0.020366 307.469)",
4848
},
4949
};
5050
// Keep this small boot-time copy in sync with the built-in palettes so
5151
// the selected theme is visible before the app has mounted.
5252
const BUILT_IN_THEME_PALETTES = {
5353
"t3-chat": {
5454
light: {
55-
background: "#fdf7fd",
56-
foreground: "#501854",
57-
accent: "#db2777",
58-
chrome: "#fdf7fd",
55+
background: "oklch(0.982446 0.010114 325.653)",
56+
foreground: "oklch(0.325698 0.116116 325.037)",
57+
accent: "oklch(0.591646 0.217985 0.584)",
58+
chrome: "oklch(0.982446 0.010114 325.653)",
5959
},
6060
dark: {
61-
background: "#1f1a24",
62-
foreground: "#f9f8fb",
63-
accent: "#a3004c",
64-
chrome: "#1f1a24",
61+
background: "oklch(0.22813 0.020366 307.469)",
62+
foreground: "oklch(0.980735 0.004092 301.426)",
63+
accent: "oklch(0.460685 0.185347 4.099)",
64+
chrome: "oklch(0.22813 0.020366 307.469)",
6565
},
6666
},
6767
grove: {
6868
light: {
69-
background: "#f3f7f4",
70-
foreground: "#241523",
71-
accent: "#1b7d50",
72-
chrome: "#f3f7f4",
69+
background: "oklch(0.972369 0.005497 157.15)",
70+
foreground: "oklch(0.222003 0.03479 328.979)",
71+
accent: "oklch(0.523295 0.112292 158.089)",
72+
chrome: "oklch(0.972369 0.005497 157.15)",
7373
},
7474
dark: {
75-
background: "#1b2821",
76-
foreground: "#fffaff",
77-
accent: "#69d69a",
78-
chrome: "#1b2821",
75+
background: "oklch(0.260865 0.02152 162.75)",
76+
foreground: "oklch(0.990339 0.008411 325.64)",
77+
accent: "oklch(0.796228 0.133058 157.319)",
78+
chrome: "oklch(0.260865 0.02152 162.75)",
7979
},
8080
},
8181
ocean: {
8282
light: {
83-
background: "#f5f7f8",
84-
foreground: "#241523",
85-
accent: "#2672af",
86-
chrome: "#f5f7f8",
83+
background: "oklch(0.974199 0.002856 241.597)",
84+
foreground: "oklch(0.222003 0.03479 328.979)",
85+
accent: "oklch(0.536684 0.120219 247.01)",
86+
chrome: "oklch(0.974199 0.002856 241.597)",
8787
},
8888
dark: {
89-
background: "#17212b",
90-
foreground: "#fffaff",
91-
accent: "#70b9ee",
92-
chrome: "#17212b",
89+
background: "oklch(0.242641 0.024125 250.573)",
90+
foreground: "oklch(0.990339 0.008411 325.64)",
91+
accent: "oklch(0.758933 0.105833 241.548)",
92+
chrome: "oklch(0.242641 0.024125 250.573)",
9393
},
9494
},
9595
ember: {
9696
light: {
97-
background: "#f9f7f5",
98-
foreground: "#241523",
99-
accent: "#ae552a",
100-
chrome: "#f9f7f5",
97+
background: "oklch(0.976527 0.002685 60.725)",
98+
foreground: "oklch(0.222003 0.03479 328.979)",
99+
accent: "oklch(0.552831 0.129438 44.656)",
100+
chrome: "oklch(0.976527 0.002685 60.725)",
101101
},
102102
dark: {
103-
background: "#291e1a",
104-
foreground: "#fffaff",
105-
accent: "#f09a64",
106-
chrome: "#291e1a",
103+
background: "oklch(0.245899 0.019144 42.044)",
104+
foreground: "oklch(0.990339 0.008411 325.64)",
105+
accent: "oklch(0.762174 0.124117 52.082)",
106+
chrome: "oklch(0.245899 0.019144 42.044)",
107107
},
108108
},
109109
iris: {
110110
light: {
111-
background: "#f8f7f9",
112-
foreground: "#241523",
113-
accent: "#7253b9",
114-
chrome: "#f8f7f9",
111+
background: "oklch(0.976531 0.003855 303.226)",
112+
foreground: "oklch(0.222003 0.03479 328.979)",
113+
accent: "oklch(0.525348 0.15373 294.176)",
114+
chrome: "oklch(0.976531 0.003855 303.226)",
115115
},
116116
dark: {
117-
background: "#1d1929",
118-
foreground: "#fffaff",
119-
accent: "#9d7df2",
120-
chrome: "#1d1929",
117+
background: "oklch(0.225975 0.031062 293.741)",
118+
foreground: "oklch(0.990339 0.008411 325.64)",
119+
accent: "oklch(0.671712 0.169136 293.929)",
120+
chrome: "oklch(0.225975 0.031062 293.741)",
121121
},
122122
},
123123
};
@@ -152,9 +152,26 @@
152152
}
153153
};
154154

155-
const isHexColor = (value) =>
156-
typeof value === "string" &&
157-
/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value);
155+
// The runtime decodes the same literal CSS formats into OKLCH. Keep
156+
// contextual colors out: they cannot be resolved from stored data.
157+
const SYSTEM_COLOR_KEYWORD =
158+
/^(?:accentcolor(?:text)?|active(?:border|caption|text)|appworkspace|background|button(?:border|face|highlight|shadow|text)|canvas(?:text)?|captiontext|field(?:text)?|graytext|highlight(?:text)?|inactive(?:border|caption(?:text)?)|info(?:background|text)|linktext|mark(?:text)?|menu(?:text)?|scrollbar|selecteditem(?:text)?|threed(?:darkshadow|face|highlight|lightshadow|shadow)|visitedtext|window(?:frame|text)?)$/i;
159+
const isThemeColor = (value) => {
160+
if (typeof value !== "string" || typeof CSS === "undefined") return false;
161+
const color = value.trim();
162+
if (
163+
/^(?:currentcolor|inherit|initial|revert|revert-layer|unset)$/i.test(color) ||
164+
SYSTEM_COLOR_KEYWORD.test(color) ||
165+
/\bfrom\b|(?:var|env|attr|calc|min|max|clamp)\(/i.test(color)
166+
) {
167+
return false;
168+
}
169+
const isLiteralColor =
170+
/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color) ||
171+
/^[a-z]+$/i.test(color) ||
172+
/^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i.test(color);
173+
return isLiteralColor && CSS.supports("color", color);
174+
};
158175
const isRecord = (value) =>
159176
typeof value === "object" && value !== null && !Array.isArray(value);
160177
const isThemeId = (value) =>
@@ -322,13 +339,13 @@
322339
const fallbackSplash = isDark ? SPLASH_COLORS.dark : SPLASH_COLORS.light;
323340
const customSplash = customColors
324341
? {
325-
background: isHexColor(customColors.canvas)
342+
background: isThemeColor(customColors.canvas)
326343
? customColors.canvas
327344
: (customDefaults?.background ?? fallbackSplash.background),
328-
foreground: isHexColor(customColors.text)
345+
foreground: isThemeColor(customColors.text)
329346
? customColors.text
330347
: (customDefaults?.foreground ?? fallbackSplash.foreground),
331-
accent: isHexColor(customColors.accent)
348+
accent: isThemeColor(customColors.accent)
332349
? customColors.accent
333350
: (customDefaults?.accent ?? fallbackSplash.accent),
334351
}
@@ -350,7 +367,7 @@
350367
}
351368
document.documentElement.classList.toggle("dark", isDark);
352369
const chromeColor = customColors
353-
? isHexColor(customColors.chrome)
370+
? isThemeColor(customColors.chrome)
354371
? customColors.chrome
355372
: (customDefaults?.chrome ?? fallbackSplash.background)
356373
: builtInSplash

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@tanstack/react-pacer": "^0.19.4",
3232
"@tanstack/react-router": "^1.160.2",
3333
"class-variance-authority": "^0.7.1",
34+
"culori": "^4.0.2",
3435
"effect": "catalog:",
3536
"jose": "catalog:",
3637
"lexical": "^0.41.0",
@@ -53,6 +54,7 @@
5354
"@tanstack/router-plugin": "^1.161.0",
5455
"@types/babel__core": "^7.20.5",
5556
"@types/compression": "^1.8.1",
57+
"@types/culori": "^4.0.1",
5658
"@types/react": "~19.2.14",
5759
"@types/react-dom": "~19.2.3",
5860
"@vercel/config": "^0.3.0",

apps/web/src/components/settings/ThemeColorPicker.tsx

Lines changed: 11 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { KeyboardEvent, PointerEvent } from "react";
22
import { memo, useCallback, useEffect, useRef, useState } from "react";
3-
import { isThemeColor, type ThemeColorRole } from "../../themePalette";
3+
import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette";
44
import { cn } from "../../lib/utils";
55
import { Input } from "../ui/input";
66
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
@@ -37,39 +37,18 @@ function clampThemeColor(value: number, min = 0, max = 1) {
3737
}
3838

3939
/**
40-
* The picker's plane and sliders operate on opaque six-digit hex, but theme
41-
* colors may carry alpha. The suffix is preserved separately and re-attached
42-
* on commit so adjusting hue or brightness cannot change transparency.
40+
* The picker remains an sRGB/hex adapter over the OKLCH palette engine. Alpha
41+
* is preserved separately and re-attached on commit so adjusting hue or
42+
* brightness cannot change transparency.
4343
*/
4444
function themePickerAlphaSuffix(value: string): string {
45-
const trimmed = value.trim().toLowerCase();
46-
const alpha = /^#[0-9a-f]{4}$/.test(trimmed)
47-
? trimmed.slice(4).repeat(2)
48-
: /^#[0-9a-f]{8}$/.test(trimmed)
49-
? trimmed.slice(7)
50-
: "";
45+
const normalized = themeColorToHex(value) ?? "";
46+
const alpha = normalized.length === 9 ? normalized.slice(7) : "";
5147
return alpha === "ff" ? "" : alpha;
5248
}
5349

5450
function normalizeThemePickerColor(value: string): string {
55-
const trimmed = value.trim();
56-
if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
57-
return `#${trimmed
58-
.slice(1)
59-
.split("")
60-
.map((character) => `${character}${character}`)
61-
.join("")}`;
62-
}
63-
if (/^#[0-9a-f]{4}$/i.test(trimmed)) {
64-
return `#${trimmed
65-
.slice(1, 4)
66-
.split("")
67-
.map((character) => `${character}${character}`)
68-
.join("")}`;
69-
}
70-
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed;
71-
if (/^#[0-9a-f]{8}$/i.test(trimmed)) return trimmed.slice(0, 7);
72-
return "#000000";
51+
return (themeColorToHex(value) ?? "#000000").slice(0, 7);
7352
}
7453

7554
function themeHexToHsv(hex: string): ThemeColorHsv {
@@ -505,6 +484,9 @@ export const ThemeColorField = memo(function ThemeColorField({
505484
const label = customLabel ?? getThemeRoleLabel(role);
506485
const isColorValue = isThemeColor(value);
507486
const swatchValue = isColorValue ? value : "#000000";
487+
const editorValue = value.trim().toLowerCase().startsWith("oklch(")
488+
? (themeColorToHex(value) ?? value)
489+
: value;
508490

509491
return (
510492
<div
@@ -542,7 +524,7 @@ export const ThemeColorField = memo(function ThemeColorField({
542524
onPointerDown={() => onSelect?.(role)}
543525
size="sm"
544526
unstyled
545-
value={value}
527+
value={editorValue}
546528
/>
547529
</div>
548530
</div>

0 commit comments

Comments
 (0)