authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-11 20:00:17+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-11 20:00:17+02:00
log083fa4ab24c464ddf01e5b7ab22135d1ebdc120b
tree0190e499a2b2d86cd08ba213376b35b2317df56c
parent35172010b131510d36d0cef54e174926e38a3013
signature Signed by PGP key B5690EEEBB952194

feat(web): use OKLCH for theme palettes (#6036)


10 files changed, 978 insertions(+), 480 deletions(-)

apps/web/index.html+72-55
...@@ -35,16 +35,16 @@...@@ -35,16 +35,16 @@
35 // app colors before React mounts.35 // app colors before React mounts.
36 const DEFAULT_THEME_PALETTES = {36 const DEFAULT_THEME_PALETTES = {
37 light: {37 light: {
38 background: "#fdf7fd",38 background: "oklch(0.982446 0.010114 325.653)",
39 foreground: "#501854",39 foreground: "oklch(0.325698 0.116116 325.037)",
40 accent: "#db2777",40 accent: "oklch(0.591646 0.217985 0.584)",
41 chrome: "#fdf7fd",41 chrome: "oklch(0.982446 0.010114 325.653)",
42 },42 },
43 dark: {43 dark: {
44 background: "#1f1a24",44 background: "oklch(0.22813 0.020366 307.469)",
45 foreground: "#f9f8fb",45 foreground: "oklch(0.980735 0.004092 301.426)",
46 accent: "#a3004c",46 accent: "oklch(0.460685 0.185347 4.099)",
47 chrome: "#1f1a24",47 chrome: "oklch(0.22813 0.020366 307.469)",
48 },48 },
49 };49 };
50 // Keep this small boot-time copy in sync with the built-in palettes so50 // Keep this small boot-time copy in sync with the built-in palettes so
...@@ -52,72 +52,72 @@...@@ -52,72 +52,72 @@
52 const BUILT_IN_THEME_PALETTES = {52 const BUILT_IN_THEME_PALETTES = {
53 "t3-chat": {53 "t3-chat": {
54 light: {54 light: {
55 background: "#fdf7fd",55 background: "oklch(0.982446 0.010114 325.653)",
56 foreground: "#501854",56 foreground: "oklch(0.325698 0.116116 325.037)",
57 accent: "#db2777",57 accent: "oklch(0.591646 0.217985 0.584)",
58 chrome: "#fdf7fd",58 chrome: "oklch(0.982446 0.010114 325.653)",
59 },59 },
60 dark: {60 dark: {
61 background: "#1f1a24",61 background: "oklch(0.22813 0.020366 307.469)",
62 foreground: "#f9f8fb",62 foreground: "oklch(0.980735 0.004092 301.426)",
63 accent: "#a3004c",63 accent: "oklch(0.460685 0.185347 4.099)",
64 chrome: "#1f1a24",64 chrome: "oklch(0.22813 0.020366 307.469)",
65 },65 },
66 },66 },
67 grove: {67 grove: {
68 light: {68 light: {
69 background: "#f3f7f4",69 background: "oklch(0.972369 0.005497 157.15)",
70 foreground: "#241523",70 foreground: "oklch(0.222003 0.03479 328.979)",
71 accent: "#1b7d50",71 accent: "oklch(0.523295 0.112292 158.089)",
72 chrome: "#f3f7f4",72 chrome: "oklch(0.972369 0.005497 157.15)",
73 },73 },
74 dark: {74 dark: {
75 background: "#1b2821",75 background: "oklch(0.260865 0.02152 162.75)",
76 foreground: "#fffaff",76 foreground: "oklch(0.990339 0.008411 325.64)",
77 accent: "#69d69a",77 accent: "oklch(0.796228 0.133058 157.319)",
78 chrome: "#1b2821",78 chrome: "oklch(0.260865 0.02152 162.75)",
79 },79 },
80 },80 },
81 ocean: {81 ocean: {
82 light: {82 light: {
83 background: "#f5f7f8",83 background: "oklch(0.974199 0.002856 241.597)",
84 foreground: "#241523",84 foreground: "oklch(0.222003 0.03479 328.979)",
85 accent: "#2672af",85 accent: "oklch(0.536684 0.120219 247.01)",
86 chrome: "#f5f7f8",86 chrome: "oklch(0.974199 0.002856 241.597)",
87 },87 },
88 dark: {88 dark: {
89 background: "#17212b",89 background: "oklch(0.242641 0.024125 250.573)",
90 foreground: "#fffaff",90 foreground: "oklch(0.990339 0.008411 325.64)",
91 accent: "#70b9ee",91 accent: "oklch(0.758933 0.105833 241.548)",
92 chrome: "#17212b",92 chrome: "oklch(0.242641 0.024125 250.573)",
93 },93 },
94 },94 },
95 ember: {95 ember: {
96 light: {96 light: {
97 background: "#f9f7f5",97 background: "oklch(0.976527 0.002685 60.725)",
98 foreground: "#241523",98 foreground: "oklch(0.222003 0.03479 328.979)",
99 accent: "#ae552a",99 accent: "oklch(0.552831 0.129438 44.656)",
100 chrome: "#f9f7f5",100 chrome: "oklch(0.976527 0.002685 60.725)",
101 },101 },
102 dark: {102 dark: {
103 background: "#291e1a",103 background: "oklch(0.245899 0.019144 42.044)",
104 foreground: "#fffaff",104 foreground: "oklch(0.990339 0.008411 325.64)",
105 accent: "#f09a64",105 accent: "oklch(0.762174 0.124117 52.082)",
106 chrome: "#291e1a",106 chrome: "oklch(0.245899 0.019144 42.044)",
107 },107 },
108 },108 },
109 iris: {109 iris: {
110 light: {110 light: {
111 background: "#f8f7f9",111 background: "oklch(0.976531 0.003855 303.226)",
112 foreground: "#241523",112 foreground: "oklch(0.222003 0.03479 328.979)",
113 accent: "#7253b9",113 accent: "oklch(0.525348 0.15373 294.176)",
114 chrome: "#f8f7f9",114 chrome: "oklch(0.976531 0.003855 303.226)",
115 },115 },
116 dark: {116 dark: {
117 background: "#1d1929",117 background: "oklch(0.225975 0.031062 293.741)",
118 foreground: "#fffaff",118 foreground: "oklch(0.990339 0.008411 325.64)",
119 accent: "#9d7df2",119 accent: "oklch(0.671712 0.169136 293.929)",
120 chrome: "#1d1929",120 chrome: "oklch(0.225975 0.031062 293.741)",
121 },121 },
122 },122 },
123 };123 };
...@@ -152,9 +152,26 @@...@@ -152,9 +152,26 @@
152 }152 }
153 };153 };
154154
155 const isHexColor = (value) =>155 // The runtime decodes the same literal CSS formats into OKLCH. Keep
156 typeof value === "string" &&156 // contextual colors out: they cannot be resolved from stored data.
157 /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value);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 };
158 const isRecord = (value) =>175 const isRecord = (value) =>
159 typeof value === "object" && value !== null && !Array.isArray(value);176 typeof value === "object" && value !== null && !Array.isArray(value);
160 const isThemeId = (value) =>177 const isThemeId = (value) =>
...@@ -322,13 +339,13 @@...@@ -322,13 +339,13 @@
322 const fallbackSplash = isDark ? SPLASH_COLORS.dark : SPLASH_COLORS.light;339 const fallbackSplash = isDark ? SPLASH_COLORS.dark : SPLASH_COLORS.light;
323 const customSplash = customColors340 const customSplash = customColors
324 ? {341 ? {
325 background: isHexColor(customColors.canvas)342 background: isThemeColor(customColors.canvas)
326 ? customColors.canvas343 ? customColors.canvas
327 : (customDefaults?.background ?? fallbackSplash.background),344 : (customDefaults?.background ?? fallbackSplash.background),
328 foreground: isHexColor(customColors.text)345 foreground: isThemeColor(customColors.text)
329 ? customColors.text346 ? customColors.text
330 : (customDefaults?.foreground ?? fallbackSplash.foreground),347 : (customDefaults?.foreground ?? fallbackSplash.foreground),
331 accent: isHexColor(customColors.accent)348 accent: isThemeColor(customColors.accent)
332 ? customColors.accent349 ? customColors.accent
333 : (customDefaults?.accent ?? fallbackSplash.accent),350 : (customDefaults?.accent ?? fallbackSplash.accent),
334 }351 }
...@@ -350,7 +367,7 @@...@@ -350,7 +367,7 @@
350 }367 }
351 document.documentElement.classList.toggle("dark", isDark);368 document.documentElement.classList.toggle("dark", isDark);
352 const chromeColor = customColors369 const chromeColor = customColors
353 ? isHexColor(customColors.chrome)370 ? isThemeColor(customColors.chrome)
354 ? customColors.chrome371 ? customColors.chrome
355 : (customDefaults?.chrome ?? fallbackSplash.background)372 : (customDefaults?.chrome ?? fallbackSplash.background)
356 : builtInSplash373 : builtInSplash
apps/web/package.json+2
...@@ -31,6 +31,7 @@...@@ -31,6 +31,7 @@
31 "@tanstack/react-pacer": "^0.19.4",31 "@tanstack/react-pacer": "^0.19.4",
32 "@tanstack/react-router": "^1.160.2",32 "@tanstack/react-router": "^1.160.2",
33 "class-variance-authority": "^0.7.1",33 "class-variance-authority": "^0.7.1",
34 "culori": "^4.0.2",
34 "effect": "catalog:",35 "effect": "catalog:",
35 "jose": "catalog:",36 "jose": "catalog:",
36 "lexical": "^0.41.0",37 "lexical": "^0.41.0",
...@@ -53,6 +54,7 @@...@@ -53,6 +54,7 @@
53 "@tanstack/router-plugin": "^1.161.0",54 "@tanstack/router-plugin": "^1.161.0",
54 "@types/babel__core": "^7.20.5",55 "@types/babel__core": "^7.20.5",
55 "@types/compression": "^1.8.1",56 "@types/compression": "^1.8.1",
57 "@types/culori": "^4.0.1",
56 "@types/react": "~19.2.14",58 "@types/react": "~19.2.14",
57 "@types/react-dom": "~19.2.3",59 "@types/react-dom": "~19.2.3",
58 "@vercel/config": "^0.3.0",60 "@vercel/config": "^0.3.0",
apps/web/src/components/settings/ThemeColorPicker.tsx+11-29
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { KeyboardEvent, PointerEvent } from "react";1import type { KeyboardEvent, PointerEvent } from "react";
2import { memo, useCallback, useEffect, useRef, useState } from "react";2import { memo, useCallback, useEffect, useRef, useState } from "react";
3import { isThemeColor, type ThemeColorRole } from "../../themePalette";3import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette";
4import { cn } from "../../lib/utils";4import { cn } from "../../lib/utils";
5import { Input } from "../ui/input";5import { Input } from "../ui/input";
6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
...@@ -37,39 +37,18 @@ function clampThemeColor(value: number, min = 0, max = 1) {...@@ -37,39 +37,18 @@ function clampThemeColor(value: number, min = 0, max = 1) {
37}37}
3838
39/**39/**
40 * The picker's plane and sliders operate on opaque six-digit hex, but theme40 * The picker remains an sRGB/hex adapter over the OKLCH palette engine. Alpha
41 * colors may carry alpha. The suffix is preserved separately and re-attached41 * is preserved separately and re-attached on commit so adjusting hue or
42 * on commit so adjusting hue or brightness cannot change transparency.42 * brightness cannot change transparency.
43 */43 */
44function themePickerAlphaSuffix(value: string): string {44function themePickerAlphaSuffix(value: string): string {
45 const trimmed = value.trim().toLowerCase();45 const normalized = themeColorToHex(value) ?? "";
46 const alpha = /^#[0-9a-f]{4}$/.test(trimmed)46 const alpha = normalized.length === 9 ? normalized.slice(7) : "";
47 ? trimmed.slice(4).repeat(2)
48 : /^#[0-9a-f]{8}$/.test(trimmed)
49 ? trimmed.slice(7)
50 : "";
51 return alpha === "ff" ? "" : alpha;47 return alpha === "ff" ? "" : alpha;
52}48}
5349
54function normalizeThemePickerColor(value: string): string {50function normalizeThemePickerColor(value: string): string {
55 const trimmed = value.trim();51 return (themeColorToHex(value) ?? "#000000").slice(0, 7);
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";
73}52}
7453
75function themeHexToHsv(hex: string): ThemeColorHsv {54function themeHexToHsv(hex: string): ThemeColorHsv {
...@@ -505,6 +484,9 @@ export const ThemeColorField = memo(function ThemeColorField({...@@ -505,6 +484,9 @@ export const ThemeColorField = memo(function ThemeColorField({
505 const label = customLabel ?? getThemeRoleLabel(role);484 const label = customLabel ?? getThemeRoleLabel(role);
506 const isColorValue = isThemeColor(value);485 const isColorValue = isThemeColor(value);
507 const swatchValue = isColorValue ? value : "#000000";486 const swatchValue = isColorValue ? value : "#000000";
487 const editorValue = value.trim().toLowerCase().startsWith("oklch(")
488 ? (themeColorToHex(value) ?? value)
489 : value;
508490
509 return (491 return (
510 <div492 <div
...@@ -542,7 +524,7 @@ export const ThemeColorField = memo(function ThemeColorField({...@@ -542,7 +524,7 @@ export const ThemeColorField = memo(function ThemeColorField({
542 onPointerDown={() => onSelect?.(role)}524 onPointerDown={() => onSelect?.(role)}
543 size="sm"525 size="sm"
544 unstyled526 unstyled
545 value={value}527 value={editorValue}
546 />528 />
547 </div>529 </div>
548 </div>530 </div>
apps/web/src/index.css+124-112
...@@ -268,16 +268,28 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -268,16 +268,28 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
268 :root {268 :root {
269 /* Dev artwork defaults. Built-in themes override these seven pigments in269 /* Dev artwork defaults. Built-in themes override these seven pigments in
270 the components layer; Nightly derives a darker matching palette. */270 the components layer; Nightly derives a darker matching palette. */
271 --stage-art-top: #67c2ff;271 --stage-art-top: oklch(0.782169 0.123386 240.226);
272 --stage-art-mid: #347ff8;272 --stage-art-mid: oklch(0.616111 0.195824 259.735);
273 --stage-art-bottom: #1538d0;273 --stage-art-bottom: oklch(0.441553 0.232394 265.474);
274 --stage-art-highlight: #d4f6ff;274 --stage-art-highlight: oklch(0.951597 0.037289 215.482);
275 --stage-art-secondary: #65c8ff;275 --stage-art-secondary: oklch(0.794668 0.12136 235.46);
276 --stage-art-tertiary: #7c8bff;276 --stage-art-tertiary: oklch(0.678991 0.170261 275.365);
277 --stage-art-line: #ddf7ff;277 --stage-art-line: oklch(0.959666 0.029238 218.179);
278 --stage-night-top: color-mix(in oklch, var(--stage-art-top) 38%, #32155b);278 --stage-night-top: color-mix(
279 --stage-night-mid: color-mix(in oklch, var(--stage-art-mid) 38%, #151443);279 in oklch,
280 --stage-night-bottom: color-mix(in oklch, var(--stage-art-bottom) 38%, #07152f);280 var(--stage-art-top) 38%,
281 oklch(0.283792 0.117327 297.201)
282 );
283 --stage-night-mid: color-mix(
284 in oklch,
285 var(--stage-art-mid) 38%,
286 oklch(0.227147 0.086086 277.99)
287 );
288 --stage-night-bottom: color-mix(
289 in oklch,
290 var(--stage-art-bottom) 38%,
291 oklch(0.200528 0.055699 261.216)
292 );
281 --stage-night-highlight: var(--stage-art-highlight);293 --stage-night-highlight: var(--stage-art-highlight);
282 --stage-night-secondary: color-mix(294 --stage-night-secondary: color-mix(
283 in oklch,295 in oklch,
...@@ -289,13 +301,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -289,13 +301,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
289 var(--stage-art-tertiary) 60%,301 var(--stage-art-tertiary) 60%,
290 var(--stage-night-top)302 var(--stage-night-top)
291 );303 );
292 --stage-night-line: color-mix(in oklch, var(--stage-art-line) 82%, white);304 --stage-night-line: color-mix(in oklch, var(--stage-art-line) 82%, oklch(1 0 0));
293 }305 }
294306
295 .dark {307 .dark {
296 --stage-art-top: #3a7ad1;308 --stage-art-top: oklch(0.581473 0.149124 256.9);
297 --stage-art-mid: #2050ae;309 --stage-art-mid: oklch(0.456509 0.159377 261.945);
298 --stage-art-bottom: #101f6e;310 --stage-art-bottom: oklch(0.291327 0.136578 267.649);
299 }311 }
300312
301 * {313 * {
...@@ -373,131 +385,131 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -373,131 +385,131 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
373 These colors stay deliberately deep at the top edge so the white stage385 These colors stay deliberately deep at the top edge so the white stage
374 wordmark and controls retain contrast in both appearance modes. */386 wordmark and controls retain contrast in both appearance modes. */
375 html[data-theme-id="t3-chat"] {387 html[data-theme-id="t3-chat"] {
376 --stage-art-top: #f28ac1;388 --stage-art-top: oklch(0.760675 0.141361 348.019);
377 --stage-art-mid: #cb3e86;389 --stage-art-mid: oklch(0.588093 0.188832 353.138);
378 --stage-art-bottom: #73194f;390 --stage-art-bottom: oklch(0.383959 0.135096 348.656);
379 --stage-art-highlight: #ffe6f6;391 --stage-art-highlight: oklch(0.949689 0.034241 339.171);
380 --stage-art-secondary: #ff82bc;392 --stage-art-secondary: oklch(0.763402 0.163836 352.525);
381 --stage-art-tertiary: #c67bf1;393 --stage-art-tertiary: oklch(0.70819 0.180285 311.949);
382 --stage-art-line: #ffe7f8;394 --stage-art-line: oklch(0.952158 0.034194 336.179);
383 }395 }
384396
385 html.dark[data-theme-id="t3-chat"] {397 html.dark[data-theme-id="t3-chat"] {
386 --stage-art-top: #a8467e;398 --stage-art-top: oklch(0.540689 0.143665 347.587);
387 --stage-art-mid: #742253;399 --stage-art-mid: oklch(0.396586 0.126592 347.6);
388 --stage-art-bottom: #390e2d;400 --stage-art-bottom: oklch(0.249959 0.079694 340.523);
389 --stage-art-highlight: #ffd8ee;401 --stage-art-highlight: oklch(0.921297 0.051708 343.229);
390 --stage-art-secondary: #de639e;402 --stage-art-secondary: oklch(0.667398 0.165674 352.549);
391 --stage-art-tertiary: #9c64cf;403 --stage-art-tertiary: oklch(0.609315 0.163722 306.315);
392 --stage-art-line: #ffe4f4;404 --stage-art-line: oklch(0.945349 0.036045 341.433);
393 }405 }
394406
395 html[data-theme-id="grove"] {407 html[data-theme-id="grove"] {
396 --stage-art-top: #6cc492;408 --stage-art-top: oklch(0.751465 0.112731 157.313);
397 --stage-art-mid: #2d8a5e;409 --stage-art-mid: oklch(0.567105 0.11099 158.859);
398 --stage-art-bottom: #174a33;410 --stage-art-bottom: oklch(0.367918 0.067635 160.558);
399 --stage-art-highlight: #e4fff0;411 --stage-art-highlight: oklch(0.976387 0.034353 161.456);
400 --stage-art-secondary: #79d8a5;412 --stage-art-secondary: oklch(0.80968 0.117071 158.817);
401 --stage-art-tertiary: #d5b563;413 --stage-art-tertiary: oklch(0.784102 0.108047 88.516);
402 --stage-art-line: #eafff2;414 --stage-art-line: oklch(0.981098 0.027812 158.718);
403 --stage-night-top: #286548;415 --stage-night-top: oklch(0.45805 0.079616 160.092);
404 --stage-night-mid: #174a35;416 --stage-night-mid: oklch(0.368483 0.065631 162.719);
405 --stage-night-bottom: #0a2c21;417 --stage-night-bottom: oklch(0.264797 0.044397 168.088);
406 --stage-night-highlight: #c4eed5;418 --stage-night-highlight: oklch(0.912563 0.055173 158.974);
407 --stage-night-secondary: #54a878;419 --stage-night-secondary: oklch(0.665652 0.109731 156.599);
408 --stage-night-tertiary: #b79b4d;420 --stage-night-tertiary: oklch(0.698651 0.103024 89.828);
409 --stage-night-line: #d7f6e2;421 --stage-night-line: oklch(0.945336 0.041923 157.222);
410 }422 }
411423
412 html.dark[data-theme-id="grove"] {424 html.dark[data-theme-id="grove"] {
413 --stage-art-top: #438e65;425 --stage-art-top: oklch(0.58719 0.09869 157.426);
414 --stage-art-mid: #286447;426 --stage-art-mid: oklch(0.454979 0.079031 159.756);
415 --stage-art-bottom: #123525;427 --stage-art-bottom: oklch(0.297856 0.050355 161.167);
416 --stage-art-highlight: #d2fbe2;428 --stage-art-highlight: oklch(0.952407 0.053872 158.44);
417 --stage-art-secondary: #62bf88;429 --stage-art-secondary: oklch(0.732591 0.120606 155.853);
418 --stage-art-tertiary: #c99b45;430 --stage-art-tertiary: oklch(0.716282 0.116547 80.563);
419 --stage-art-line: #e0fae9;431 --stage-art-line: oklch(0.961577 0.035285 157.03);
420 --stage-night-top: #24523a;432 --stage-night-top: oklch(0.398632 0.065534 158.601);
421 --stage-night-mid: #113323;433 --stage-night-mid: oklch(0.290561 0.049694 160.456);
422 --stage-night-bottom: #071d16;434 --stage-night-bottom: oklch(0.210147 0.03173 169.818);
423 --stage-night-highlight: #b5dfc4;435 --stage-night-highlight: oklch(0.866303 0.057526 156.796);
424 --stage-night-secondary: #478d66;436 --stage-night-secondary: oklch(0.586553 0.093722 157.365);
425 --stage-night-tertiary: #a9853b;437 --stage-night-tertiary: oklch(0.6364 0.101769 82.985);
426 --stage-night-line: #d0ead9;438 --stage-night-line: oklch(0.913292 0.035718 156.976);
427 }439 }
428440
429 html[data-theme-id="ocean"] {441 html[data-theme-id="ocean"] {
430 --stage-art-top: #77c9e3;442 --stage-art-top: oklch(0.792993 0.088072 220.421);
431 --stage-art-mid: #3384bd;443 --stage-art-mid: oklch(0.590296 0.116841 243.184);
432 --stage-art-bottom: #174d7a;444 --stage-art-bottom: oklch(0.409123 0.093718 247.98);
433 --stage-art-highlight: #e5faff;445 --stage-art-highlight: oklch(0.971217 0.023008 213.615);
434 --stage-art-secondary: #70c9df;446 --stage-art-secondary: oklch(0.788391 0.090856 215.684);
435 --stage-art-tertiary: #5cc7be;447 --stage-art-tertiary: oklch(0.76441 0.099607 187.893);
436 --stage-art-line: #e9fbff;448 --stage-art-line: oklch(0.976025 0.019647 212.543);
437 }449 }
438450
439 html.dark[data-theme-id="ocean"] {451 html.dark[data-theme-id="ocean"] {
440 --stage-art-top: #4288ac;452 --stage-art-top: oklch(0.59663 0.089167 233.427);
441 --stage-art-mid: #285d84;453 --stage-art-mid: oklch(0.461094 0.084904 243.478);
442 --stage-art-bottom: #132e49;454 --stage-art-bottom: oklch(0.294818 0.05947 250.526);
443 --stage-art-highlight: #d9f5ff;455 --stage-art-highlight: oklch(0.952907 0.032224 221.27);
444 --stage-art-secondary: #61b5d4;456 --stage-art-secondary: oklch(0.732079 0.09296 224.414);
445 --stage-art-tertiary: #51b8b3;457 --stage-art-tertiary: oklch(0.720885 0.095495 190.903);
446 --stage-art-line: #dff7ff;458 --stage-art-line: oklch(0.961039 0.027355 219.756);
447 }459 }
448460
449 html[data-theme-id="ember"] {461 html[data-theme-id="ember"] {
450 --stage-art-top: #f4a26e;462 --stage-art-top: oklch(0.782439 0.118806 52.569);
451 --stage-art-mid: #d66036;463 --stage-art-mid: oklch(0.62863 0.15922 39.401);
452 --stage-art-bottom: #7c291e;464 --stage-art-bottom: oklch(0.404251 0.117664 30.424);
453 --stage-art-highlight: #fff0df;465 --stage-art-highlight: oklch(0.962469 0.02774 70.95);
454 --stage-art-secondary: #ffad77;466 --stage-art-secondary: oklch(0.815735 0.11904 53.659);
455 --stage-art-tertiary: #ef6f69;467 --stage-art-tertiary: oklch(0.69361 0.159346 24.787);
456 --stage-art-line: #fff3e7;468 --stage-art-line: oklch(0.970313 0.02052 67.583);
457 --stage-night-top: #8a442c;469 --stage-night-top: oklch(0.470236 0.102322 39.355);
458 --stage-night-mid: #5c271c;470 --stage-night-mid: oklch(0.345367 0.080697 33.217);
459 --stage-night-bottom: #32100d;471 --stage-night-bottom: oklch(0.227386 0.056077 27.437);
460 --stage-night-highlight: #ffd6ba;472 --stage-night-highlight: oklch(0.903891 0.059281 56.181);
461 --stage-night-secondary: #cb7148;473 --stage-night-secondary: oklch(0.641705 0.126508 44.376);
462 --stage-night-tertiary: #ad4b45;474 --stage-night-tertiary: oklch(0.538694 0.129931 25.865);
463 --stage-night-line: #ffe0c9;475 --stage-night-line: oklch(0.926348 0.046029 58.73);
464 }476 }
465477
466 html.dark[data-theme-id="ember"] {478 html.dark[data-theme-id="ember"] {
467 --stage-art-top: #ba6540;479 --stage-art-top: oklch(0.597533 0.120694 43.455);
468 --stage-art-mid: #803a29;480 --stage-art-mid: oklch(0.437763 0.101287 34.86);
469 --stage-art-bottom: #3c1916;481 --stage-art-bottom: oklch(0.264269 0.055858 26.548);
470 --stage-art-highlight: #ffe1cd;482 --stage-art-highlight: oklch(0.929214 0.042638 55.801);
471 --stage-art-secondary: #e68156;483 --stage-art-secondary: oklch(0.705592 0.137369 43.176);
472 --stage-art-tertiary: #d85b58;484 --stage-art-tertiary: oklch(0.629583 0.158322 24.088);
473 --stage-art-line: #ffe8d7;485 --stage-art-line: oklch(0.945058 0.033906 58.824);
474 --stage-night-top: #6a3425;486 --stage-night-top: oklch(0.392352 0.081287 36.444);
475 --stage-night-mid: #3e1b15;487 --stage-night-mid: oklch(0.271305 0.056352 31.135);
476 --stage-night-bottom: #1d0d0b;488 --stage-night-bottom: oklch(0.182126 0.028154 27.774);
477 --stage-night-highlight: #efc4a9;489 --stage-night-highlight: oklch(0.851007 0.061294 53.805);
478 --stage-night-secondary: #a85e3f;490 --stage-night-secondary: oklch(0.560789 0.10645 42.953);
479 --stage-night-tertiary: #8f413e;491 --stage-night-tertiary: oklch(0.476228 0.106656 24.165);
480 --stage-night-line: #f2d2bc;492 --stage-night-line: oklch(0.884931 0.046607 56.556);
481 }493 }
482494
483 html[data-theme-id="iris"] {495 html[data-theme-id="iris"] {
484 --stage-art-top: #b49ae8;496 --stage-art-top: oklch(0.738305 0.113454 298.926);
485 --stage-art-mid: #7a5ac4;497 --stage-art-mid: oklch(0.551826 0.159189 294.531);
486 --stage-art-bottom: #422d80;498 --stage-art-bottom: oklch(0.37147 0.133317 289.999);
487 --stage-art-highlight: #f2eaff;499 --stage-art-highlight: oklch(0.949472 0.029204 303.081);
488 --stage-art-secondary: #b79af1;500 --stage-art-secondary: oklch(0.745085 0.125892 298.647);
489 --stage-art-tertiary: #ec79c7;501 --stage-art-tertiary: oklch(0.73066 0.167815 340.964);
490 --stage-art-line: #f6eeff;502 --stage-art-line: oklch(0.960278 0.024064 306.969);
491 }503 }
492504
493 html.dark[data-theme-id="iris"] {505 html.dark[data-theme-id="iris"] {
494 --stage-art-top: #8063c4;506 --stage-art-top: oklch(0.57297 0.145973 295.185);
495 --stage-art-mid: #513990;507 --stage-art-mid: oklch(0.419499 0.13752 292.131);
496 --stage-art-bottom: #261c53;508 --stage-art-bottom: oklch(0.274235 0.095798 286.608);
497 --stage-art-highlight: #e8ddff;509 --stage-art-highlight: oklch(0.916698 0.047206 300.224);
498 --stage-art-secondary: #9e83dc;510 --stage-art-secondary: oklch(0.670994 0.13095 296.689);
499 --stage-art-tertiary: #d96ab7;511 --stage-art-tertiary: oklch(0.679357 0.165376 340.439);
500 --stage-art-line: #eee7ff;512 --stage-art-line: oklch(0.940582 0.032921 299.076);
501 }513 }
502514
503 .workspace-topbar {515 .workspace-topbar {
apps/web/src/themeBoot.test.ts+66-3
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 OCEAN_THEME,15 OCEAN_THEME,
16 THEME_APPEARANCE_MODE_STORAGE_KEY,16 THEME_APPEARANCE_MODE_STORAGE_KEY,
17 THEME_FOLLOW_SYSTEM_STORAGE_KEY,17 THEME_FOLLOW_SYSTEM_STORAGE_KEY,
18 toCanonicalThemeColor,
18} from "./themePalette";19} from "./themePalette";
1920
20const THEME_STORAGE_KEY = "t3code:theme";21const THEME_STORAGE_KEY = "t3code:theme";
...@@ -83,7 +84,12 @@ function runBootScript(options: {...@@ -83,7 +84,12 @@ function runBootScript(options: {
83 matchMedia: () => ({ matches: options.prefersDark }),84 matchMedia: () => ({ matches: options.prefersDark }),
84 };85 };
8586
86 new Function("window", "document", bootScript)(fakeWindow, fakeDocument);87 const fakeCss = {
88 supports: (property: string, value: string) =>
89 property === "color" && toCanonicalThemeColor(value) !== null,
90 };
91
92 new Function("window", "document", "CSS", bootScript)(fakeWindow, fakeDocument, fakeCss);
8793
88 return {94 return {
89 isDark: classes.has("dark"),95 isDark: classes.has("dark"),
...@@ -275,6 +281,63 @@ describe("index.html boot script", () => {...@@ -275,6 +281,63 @@ describe("index.html boot script", () => {
275 expect(aurora.metaContent).toBe(DEFAULT_DARK_CHROME);281 expect(aurora.metaContent).toBe(DEFAULT_DARK_CHROME);
276 });282 });
277283
284 it("accepts exponent-form OKLCH before the runtime mounts", () => {
285 const colors = {
286 canvas: "oklch(9.5e-1 1e-2 2.8e2)",
287 chrome: "oklch(9.4e-1 1e-2 2.8e2)",
288 text: "oklch(2e-1 0 0 / 9e-1)",
289 accent: "oklch(6.2e-1 0.2 2.8e2)",
290 };
291 const boot = runBootScript({
292 storage: {
293 [THEME_STORAGE_KEY]: "scientific",
294 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
295 {
296 id: "scientific",
297 label: "Scientific",
298 appearance: "light",
299 colors,
300 },
301 ]),
302 },
303 prefersDark: false,
304 });
305
306 expect(boot.bootVariables["--boot-background"]).toBe(colors.canvas);
307 expect(boot.bootVariables["--boot-foreground"]).toBe(colors.text);
308 expect(boot.bootVariables["--boot-accent"]).toBe(colors.accent);
309 expect(boot.backgroundColor).toBe(colors.chrome);
310 expect(boot.metaContent).toBe(colors.chrome);
311 });
312
313 it("accepts legacy CSS color formats before the runtime mounts", () => {
314 const colors = {
315 canvas: "rgb(248 251 255)",
316 chrome: "hsl(210 100% 99%)",
317 text: "rebeccapurple",
318 accent: "color(display-p3 0.36 0.42 1)",
319 };
320 const boot = runBootScript({
321 storage: {
322 [THEME_STORAGE_KEY]: "legacy-css",
323 [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([
324 {
325 id: "legacy-css",
326 label: "Legacy CSS",
327 appearance: "light",
328 colors,
329 },
330 ]),
331 },
332 prefersDark: false,
333 });
334
335 expect(boot.bootVariables["--boot-background"]).toBe(colors.canvas);
336 expect(boot.bootVariables["--boot-foreground"]).toBe(colors.text);
337 expect(boot.bootVariables["--boot-accent"]).toBe(colors.accent);
338 expect(boot.backgroundColor).toBe(colors.chrome);
339 });
340
278 // Asserting against the real palette definitions (not literals) turns the341 // Asserting against the real palette definitions (not literals) turns the
279 // boot script's hand-maintained copy into a CI-enforced contract: any342 // boot script's hand-maintained copy into a CI-enforced contract: any
280 // palette change breaks this test until the copy in index.html is updated.343 // palette change breaks this test until the copy in index.html is updated.
...@@ -410,9 +473,9 @@ describe("index.html boot script", () => {...@@ -410,9 +473,9 @@ describe("index.html boot script", () => {
410 });473 });
411474
412 expect(boot.themeId).toBe("partial");475 expect(boot.themeId).toBe("partial");
413 expect(boot.bootVariables["--boot-background"]).toBe("#1f1a24");476 expect(boot.bootVariables["--boot-background"]).toBe(getDefaultThemeColors("dark").canvas);
414 expect(boot.bootVariables["--boot-foreground"]).toBe("#fffaff");477 expect(boot.bootVariables["--boot-foreground"]).toBe("#fffaff");
415 expect(boot.bootVariables["--boot-accent"]).toBe("#a3004c");478 expect(boot.bootVariables["--boot-accent"]).toBe(getDefaultThemeColors("dark").accent);
416 expect(boot.backgroundColor).toBe(DEFAULT_DARK_CHROME);479 expect(boot.backgroundColor).toBe(DEFAULT_DARK_CHROME);
417 expect(boot.metaContent).toBe(DEFAULT_DARK_CHROME);480 expect(boot.metaContent).toBe(DEFAULT_DARK_CHROME);
418 });481 });
apps/web/src/themePalette.test.ts+285-48
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 canonicalThemePreference,15 canonicalThemePreference,
16 parseThemeFile,16 parseThemeFile,
17 parseThemeHalves,17 parseThemeHalves,
18 removeCustomTheme,
18 resolveDesktopTheme,19 resolveDesktopTheme,
19 resolveThemeAppearance,20 resolveThemeAppearance,
20 serializeThemeFile,21 serializeThemeFile,
...@@ -31,12 +32,35 @@ import {...@@ -31,12 +32,35 @@ import {
31 createManagedThemeColors,32 createManagedThemeColors,
32 createVividThemeColors,33 createVividThemeColors,
33 getDefaultThemeColors,34 getDefaultThemeColors,
35 themeColorToHex,
36 toCanonicalThemeColor,
34 THEME_FILE_VERSION,37 THEME_FILE_VERSION,
35} from "./themePalette";38} from "./themePalette";
3639
40function asHex(value: string): string {
41 const hex = themeColorToHex(value);
42 if (!hex) throw new Error(`Expected a theme color, received ${value}`);
43 return hex.slice(0, 7);
44}
45
46function canonical(value: string): string {
47 const color = toCanonicalThemeColor(value);
48 if (!color) throw new Error(`Expected a theme color, received ${value}`);
49 return color;
50}
51
52function expectThemeColors(
53 colors: Readonly<Record<string, string>>,
54 expected: Readonly<Record<string, string>>,
55): void {
56 for (const [role, value] of Object.entries(expected)) {
57 expect(asHex(colors[role]!)).toBe(value);
58 }
59}
60
37function contrastRatio(first: string, second: string): number {61function contrastRatio(first: string, second: string): number {
38 const toRgb = (value: string) => {62 const toRgb = (value: string) => {
39 const hex = value.slice(1);63 const hex = asHex(value).slice(1);
40 return [0, 1, 2].map(64 return [0, 1, 2].map(
41 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,65 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
42 );66 );
...@@ -56,8 +80,8 @@ describe("theme files", () => {...@@ -56,8 +80,8 @@ describe("theme files", () => {
56 const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00");80 const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00");
57 const darkDefaults = getDefaultThemeColors("dark");81 const darkDefaults = getDefaultThemeColors("dark");
5882
59 expect(light.canvas).not.toBe("#111827");83 expect(asHex(light.canvas)).not.toBe("#111827");
60 expect(dark.canvas).not.toBe("#ffffff");84 expect(asHex(dark.canvas)).not.toBe("#ffffff");
61 expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5);85 expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5);
62 expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5);86 expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5);
63 expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5);87 expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5);
...@@ -73,7 +97,7 @@ describe("theme files", () => {...@@ -73,7 +97,7 @@ describe("theme files", () => {
73 // Status colors fall back to T3 Code's standard red and amber rather than97 // Status colors fall back to T3 Code's standard red and amber rather than
74 // the flagship palette's, so no generated theme inherits a brand tint.98 // the flagship palette's, so no generated theme inherits a brand tint.
75 const channels = (value: string) =>99 const channels = (value: string) =>
76 [1, 3, 5].map((index) => Number.parseInt(value.slice(index, index + 2), 16)) as [100 [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [
77 number,101 number,
78 number,102 number,
79 number,103 number,
...@@ -92,7 +116,7 @@ describe("theme files", () => {...@@ -92,7 +116,7 @@ describe("theme files", () => {
92 expect(warnRed).toBeGreaterThan(warnBlue);116 expect(warnRed).toBeGreaterThan(warnBlue);
93 expect(warnGreen).toBeGreaterThan(warnBlue);117 expect(warnGreen).toBeGreaterThan(warnBlue);
94 }118 }
95 expect(dark.error).not.toBe(darkDefaults.error);119 expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error));
96 });120 });
97121
98 it("derives readable, distinctive vivid palettes from exact seeds", () => {122 it("derives readable, distinctive vivid palettes from exact seeds", () => {
...@@ -108,8 +132,10 @@ describe("theme files", () => {...@@ -108,8 +132,10 @@ describe("theme files", () => {
108 for (const [appearance, canvas, accent] of seeds) {132 for (const [appearance, canvas, accent] of seeds) {
109 const colors = createVividThemeColors(appearance, canvas, accent);133 const colors = createVividThemeColors(appearance, canvas, accent);
110 // Exact seeds are honored.134 // Exact seeds are honored.
111 expect(colors.canvas).toBe(canvas);135 expect(colors.canvas).toMatch(/^oklch\(/);
112 expect(colors.accent).toBe(accent);136 expect(colors.accent).toMatch(/^oklch\(/);
137 expect(asHex(colors.canvas)).toBe(canvas);
138 expect(asHex(colors.accent)).toBe(accent);
113 // Readability is solved per surface.139 // Readability is solved per surface.
114 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7);140 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7);
115 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5);141 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5);
...@@ -131,7 +157,7 @@ describe("theme files", () => {...@@ -131,7 +157,7 @@ describe("theme files", () => {
131 // The companion action is a distinct voice, not the accent again.157 // The companion action is a distinct voice, not the accent again.
132 expect(colors.messageAction).not.toBe(colors.accent);158 expect(colors.messageAction).not.toBe(colors.accent);
133 // Update family follows the theme, not the default palette.159 // Update family follows the theme, not the default palette.
134 expect(colors.update).toBe(accent);160 expect(asHex(colors.update)).toBe(accent);
135 }161 }
136 });162 });
137163
...@@ -170,13 +196,53 @@ describe("theme files", () => {...@@ -170,13 +196,53 @@ describe("theme files", () => {
170 label: "Ocean dusk",196 label: "Ocean dusk",
171 appearance: "dark",197 appearance: "dark",
172 colors: {198 colors: {
173 canvas: "#07152f",199 canvas: canonical("#07152f"),
174 accent: "#67c2ff",200 accent: canonical("#67c2ff"),
175 placeholder: "#968d9f",201 placeholder: canonical("#968d9f"),
176 },202 },
177 });203 });
178 });204 });
179205
206 it("decodes literal CSS color formats into OKLCH without dropping alpha", () => {
207 const theme = parseThemeFile({
208 version: THEME_FILE_VERSION,
209 name: "Translucent",
210 appearance: "light",
211 colors: {
212 canvas: "oklch(62% 0.2 280deg / 50%)",
213 accent: "#abcd",
214 focus: "rgb(10 20 30 / 50%)",
215 error: "hsl(350 80% 50%)",
216 warning: "hwb(45 10% 20%)",
217 update: "lab(60% 40 30)",
218 messageAction: "lch(60% 50 120)",
219 sidebar: "oklab(0.6 0.1 -0.1)",
220 terminalCursor: "color(display-p3 0.8 0.2 0.3)",
221 terminalSelection: "rebeccapurple",
222 terminalScrollbar: "transparent",
223 terminalScrollbarHover: "rgb(10 20 30 / none)",
224 },
225 });
226
227 expect(theme.colors.canvas).toBe("oklch(0.62 0.2 280 / 0.5)");
228 expect(theme.colors.accent).toBe(canonical("#abcd"));
229 expect(themeColorToHex(theme.colors.accent)).toBe("#aabbccdd");
230 expect(themeColorToHex(theme.colors.focus)).toBe("#0a141e80");
231 expect(themeColorToHex(theme.colors.terminalSelection)).toBe("#663399");
232 expect(theme.colors.terminalScrollbar).toBe("oklch(0 0 0 / 0)");
233 expect(themeColorToHex(theme.colors.terminalScrollbarHover)).toBe("#0a141e00");
234 for (const role of [
235 "error",
236 "warning",
237 "update",
238 "messageAction",
239 "sidebar",
240 "terminalCursor",
241 ] as const) {
242 expect(theme.colors[role]).toMatch(/^oklch\(/);
243 }
244 });
245
180 it("rejects unknown roles and invalid color values", () => {246 it("rejects unknown roles and invalid color values", () => {
181 expect(() =>247 expect(() =>
182 parseThemeFile({248 parseThemeFile({
...@@ -194,16 +260,20 @@ describe("theme files", () => {...@@ -194,16 +260,20 @@ describe("theme files", () => {
194 appearance: "light",260 appearance: "light",
195 colors: { accent: "var(--danger)" },261 colors: { accent: "var(--danger)" },
196 }),262 }),
197 ).toThrow('The color for "accent" must be a hex color');263 ).toThrow('The color for "accent" must be a literal CSS color');
198 });264 });
199265
200 it("serializes a theme back into the importable file shape", () => {266 it("canonicalizes the explicitly exported theme", () => {
201 const serialized = serializeThemeFile(T3_CHAT_THEME);267 const serialized = serializeThemeFile({
268 ...T3_CHAT_THEME,
269 colors: { ...T3_CHAT_THEME.colors, accent: "hsl(263 70% 58%)" },
270 });
202 expect(JSON.parse(serialized)).toMatchObject({271 expect(JSON.parse(serialized)).toMatchObject({
203 version: THEME_FILE_VERSION,272 version: THEME_FILE_VERSION,
204 id: T3_CHAT_THEME.id,273 id: T3_CHAT_THEME.id,
205 name: T3_CHAT_THEME.label,274 name: T3_CHAT_THEME.label,
206 appearance: "light",275 appearance: "light",
276 colors: { accent: canonical("hsl(263 70% 58%)") },
207 });277 });
208 });278 });
209279
...@@ -257,8 +327,8 @@ describe("theme files", () => {...@@ -257,8 +327,8 @@ describe("theme files", () => {
257327
258 expect(getThemeModes(theme)).toEqual(["light", "dark"]);328 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
259 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({329 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({
260 canvas: "#101827",330 canvas: canonical("#101827"),
261 text: "#eef5ff",331 text: canonical("#eef5ff"),
262 });332 });
263 expect(getThemeModes(T3_CHAT_THEME)).toEqual(["light", "dark"]);333 expect(getThemeModes(T3_CHAT_THEME)).toEqual(["light", "dark"]);
264 expect(resolveThemeAppearance(T3_CHAT_THEME.id, true, true)).toBe("dark");334 expect(resolveThemeAppearance(T3_CHAT_THEME.id, true, true)).toBe("dark");
...@@ -266,13 +336,13 @@ describe("theme files", () => {...@@ -266,13 +336,13 @@ describe("theme files", () => {
266 expect(resolveThemeAppearance(T3_CHAT_THEME.id, false, false, "dark")).toBe("dark");336 expect(resolveThemeAppearance(T3_CHAT_THEME.id, false, false, "dark")).toBe("dark");
267 expect(resolveDesktopTheme(T3_CHAT_THEME.id, false, "dark")).toBe("dark");337 expect(resolveDesktopTheme(T3_CHAT_THEME.id, false, "dark")).toBe("dark");
268 expect(JSON.parse(serializeThemeFile(theme)).variants.dark).toMatchObject({338 expect(JSON.parse(serializeThemeFile(theme)).variants.dark).toMatchObject({
269 canvas: "#101827",339 canvas: canonical("#101827"),
270 text: "#eef5ff",340 text: canonical("#eef5ff"),
271 });341 });
272 });342 });
273343
274 it("keeps the T3 Chat palette faithful and readable", () => {344 it("keeps the T3 Chat palette faithful and readable", () => {
275 expect(T3_CHAT_THEME.colors).toMatchObject({345 expectThemeColors(T3_CHAT_THEME.colors, {
276 canvas: "#fdf7fd",346 canvas: "#fdf7fd",
277 chrome: "#fdf7fd",347 chrome: "#fdf7fd",
278 toolbarBorder: "#efbdeb",348 toolbarBorder: "#efbdeb",
...@@ -287,7 +357,7 @@ describe("theme files", () => {...@@ -287,7 +357,7 @@ describe("theme files", () => {
287 accentSurface: "#f3e6f5",357 accentSurface: "#f3e6f5",
288 sidebar: "#f2e1f4",358 sidebar: "#f2e1f4",
289 });359 });
290 expect(T3_CHAT_THEME.variants?.dark).toMatchObject({360 expectThemeColors(T3_CHAT_THEME.variants!.dark!, {
291 canvas: "#1f1a24",361 canvas: "#1f1a24",
292 chrome: "#1f1a24",362 chrome: "#1f1a24",
293 surface: "#29232d",363 surface: "#29232d",
...@@ -321,8 +391,8 @@ describe("theme files", () => {...@@ -321,8 +391,8 @@ describe("theme files", () => {
321 expect(getThemeModes(theme)).toEqual(["light", "dark"]);391 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
322 expect(theme.sidebarArtwork).toBe(true);392 expect(theme.sidebarArtwork).toBe(true);
323 expect(themeAllowsSidebarArtwork(theme.id)).toBe(true);393 expect(themeAllowsSidebarArtwork(theme.id)).toBe(true);
324 expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i);394 expect(theme.colors.accent).toMatch(/^oklch\(/);
325 expect(theme.variants?.dark?.accent).toMatch(/^#[0-9a-f]{6}$/i);395 expect(theme.variants?.dark?.accent).toMatch(/^oklch\(/);
326396
327 for (const mode of ["light", "dark"] as const) {397 for (const mode of ["light", "dark"] as const) {
328 const colors = getThemeColorsForMode(theme, mode);398 const colors = getThemeColorsForMode(theme, mode);
...@@ -380,7 +450,9 @@ describe("theme files", () => {...@@ -380,7 +450,9 @@ describe("theme files", () => {
380 });450 });
381451
382 expect(getThemeModes(theme)).toEqual(["dark"]);452 expect(getThemeModes(theme)).toEqual(["dark"]);
383 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ canvas: "#111827" });453 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({
454 canvas: canonical("#111827"),
455 });
384 expect(getThemeColorsForMode(theme, "light")).toBeNull();456 expect(getThemeColorsForMode(theme, "light")).toBeNull();
385 });457 });
386458
...@@ -419,8 +491,16 @@ describe("theme files", () => {...@@ -419,8 +491,16 @@ describe("theme files", () => {
419 vi.unstubAllGlobals();491 vi.unstubAllGlobals();
420 });492 });
421493
422 it("updates a personal theme without changing its id", () => {494 it("canonicalizes explicit writes without migrating untouched themes", () => {
423 const stored = new Map<string, string>();495 const stored = new Map<string, string>();
496 const untouchedTheme = {
497 id: "legacy",
498 label: "Legacy",
499 appearance: "dark",
500 colors: { accent: "#5b6cff", futureRole: "hsl(10 20% 30%)" },
501 futureMetadata: { version: 2 },
502 };
503 stored.set(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify([untouchedTheme]));
424 vi.stubGlobal("window", {504 vi.stubGlobal("window", {
425 localStorage: {505 localStorage: {
426 getItem: (key: string) => stored.get(key) ?? null,506 getItem: (key: string) => stored.get(key) ?? null,
...@@ -442,23 +522,175 @@ describe("theme files", () => {...@@ -442,23 +522,175 @@ describe("theme files", () => {
442 const updatedTheme = updateCustomTheme({522 const updatedTheme = updateCustomTheme({
443 ...createdTheme,523 ...createdTheme,
444 label: "Aurora Night",524 label: "Aurora Night",
445 colors: { ...createdTheme.colors, accent: "#7c3aed" },525 colors: { ...createdTheme.colors, accent: "hsl(263 70% 58%)" },
446 });526 });
447527
448 expect(updatedTheme).toMatchObject({528 expect(updatedTheme).toMatchObject({
449 id: "aurora",529 id: "aurora",
450 label: "Aurora Night",530 label: "Aurora Night",
531 colors: { accent: canonical("hsl(263 70% 58%)") },
451 });532 });
452 expect(updatedTheme).not.toHaveProperty("sidebarArtwork");533 expect(updatedTheme).not.toHaveProperty("sidebarArtwork");
534 const storedThemes = JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]");
535 expect(storedThemes[0]).toEqual(untouchedTheme);
536 expect(storedThemes[1]).toMatchObject({
537 id: "aurora",
538 label: "Aurora Night",
539 colors: { accent: canonical("hsl(263 70% 58%)") },
540 });
541 expect(storedThemes[1]).not.toHaveProperty("sidebarArtwork");
453 invalidateCustomThemes();542 invalidateCustomThemes();
454 expect(getCustomThemes()).toEqual([updatedTheme]);543 expect(getCustomThemes().find((theme) => theme.id === "aurora")).toMatchObject({
455 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({
456 id: "aurora",544 id: "aurora",
545 colors: { accent: canonical("hsl(263 70% 58%)") },
546 });
547 removeCustomTheme("aurora");
548 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")).toEqual([untouchedTheme]);
549
550 vi.unstubAllGlobals();
551 invalidateCustomThemes();
552 });
553
554 it("writes from the cached raw snapshot without risking a destructive reread", () => {
555 const legacyTheme = {
556 id: "legacy",
557 label: "Legacy",
558 appearance: "dark",
559 colors: { accent: "#5b6cff" },
560 futureMetadata: true,
561 };
562 let storedThemes = JSON.stringify([legacyTheme]);
563 let readCount = 0;
564 const setItem = vi.fn((_key: string, value: string) => {
565 storedThemes = value;
566 });
567 vi.stubGlobal("window", {
568 localStorage: {
569 getItem: () => {
570 readCount += 1;
571 if (readCount > 1) throw new Error("transient read failure");
572 return storedThemes;
573 },
574 setItem,
575 },
576 });
577
578 invalidateCustomThemes();
579 expect(getCustomThemes()).toHaveLength(1);
580 installCustomTheme(
581 parseThemeFile({
582 version: THEME_FILE_VERSION,
583 id: "aurora",
584 name: "Aurora",
585 appearance: "light",
586 colors: { accent: "hsl(263 70% 58%)" },
587 }),
588 );
589
590 expect(readCount).toBe(1);
591 expect(setItem).toHaveBeenCalledOnce();
592 expect(JSON.parse(storedThemes)[0]).toEqual(legacyTheme);
593
594 vi.unstubAllGlobals();
595 invalidateCustomThemes();
596 });
597
598 it("refuses to overwrite a theme library that could not be read", () => {
599 const setItem = vi.fn();
600 vi.stubGlobal("window", {
601 localStorage: {
602 getItem: () => {
603 throw new Error("storage unavailable");
604 },
605 setItem,
606 },
607 });
608
609 invalidateCustomThemes();
610 expect(getCustomThemes()).toEqual([]);
611 expect(() =>
612 installCustomTheme(
613 parseThemeFile({
614 version: THEME_FILE_VERSION,
615 id: "aurora",
616 name: "Aurora",
617 appearance: "light",
618 colors: { accent: "#5b6cff" },
619 }),
620 ),
621 ).toThrow(`Failed to read the theme library from ${CUSTOM_THEMES_STORAGE_KEY}.`);
622 expect(setItem).not.toHaveBeenCalled();
623
624 vi.unstubAllGlobals();
625 invalidateCustomThemes();
626 });
627
628 it("rejects malformed stored entries that reuse an installed theme id", () => {
629 const storedThemes = JSON.stringify([{ id: "aurora", malformed: true }]);
630 const setItem = vi.fn();
631 vi.stubGlobal("window", {
632 localStorage: {
633 getItem: () => storedThemes,
634 setItem,
635 },
636 });
637
638 invalidateCustomThemes();
639 expect(() =>
640 installCustomTheme(
641 parseThemeFile({
642 version: THEME_FILE_VERSION,
643 id: "aurora",
644 name: "Aurora",
645 appearance: "light",
646 colors: { accent: "#5b6cff" },
647 }),
648 ),
649 ).toThrow('A theme named "Aurora" is already installed.');
650 expect(setItem).not.toHaveBeenCalled();
651
652 vi.unstubAllGlobals();
653 invalidateCustomThemes();
654 });
655
656 it("collapses duplicate raw entries when their theme is explicitly updated", () => {
657 const stored = new Map<string, string>();
658 const theme = {
659 id: "aurora",
660 label: "Aurora",
661 appearance: "light",
662 colors: { accent: "#5b6cff" },
663 };
664 const untouchedTheme = { id: "future", malformed: true, metadata: { version: 2 } };
665 stored.set(
666 CUSTOM_THEMES_STORAGE_KEY,
667 JSON.stringify([theme, { id: "aurora", malformed: true }, untouchedTheme]),
668 );
669 vi.stubGlobal("window", {
670 localStorage: {
671 getItem: (key: string) => stored.get(key) ?? null,
672 setItem: (key: string, value: string) => stored.set(key, value),
673 },
674 });
675
676 invalidateCustomThemes();
677 const installedTheme = getCustomThemes()[0]!;
678 updateCustomTheme({
679 ...installedTheme,
457 label: "Aurora Night",680 label: "Aurora Night",
681 colors: { ...installedTheme.colors, accent: "hsl(263 70% 58%)" },
458 });682 });
459 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).not.toHaveProperty(683
460 "sidebarArtwork",684 const updatedLibrary = JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]");
685 expect(updatedLibrary.filter((entry: { id?: string }) => entry.id === "aurora")).toHaveLength(
686 1,
461 );687 );
688 expect(updatedLibrary[0]).toMatchObject({
689 id: "aurora",
690 label: "Aurora Night",
691 colors: { accent: canonical("hsl(263 70% 58%)") },
692 });
693 expect(updatedLibrary[1]).toEqual(untouchedTheme);
462694
463 vi.unstubAllGlobals();695 vi.unstubAllGlobals();
464 invalidateCustomThemes();696 invalidateCustomThemes();
...@@ -530,23 +762,23 @@ describe("stored theme preferences", () => {...@@ -530,23 +762,23 @@ describe("stored theme preferences", () => {
530 expect(isKnownThemePreference("missing-theme")).toBe(false);762 expect(isKnownThemePreference("missing-theme")).toBe(false);
531 });763 });
532764
533 it("keeps stored themes with unknown roles and drops invalid entries", () => {765 it("decodes stored colors in memory without writing during reads", () => {
766 const storedThemes = JSON.stringify([
767 {
768 id: "aurora",
769 label: "Aurora",
770 appearance: "light",
771 colors: { canvas: "#f8fbff", futureRole: "#123456", accent: "not-a-color" },
772 variants: { dark: { canvas: "rgb(16 24 39)" } },
773 },
774 { id: "light", label: "Reserved", appearance: "light", colors: {} },
775 { id: "aurora", label: "Duplicate", appearance: "dark", colors: {} },
776 ]);
777 const setItem = vi.fn();
534 vi.stubGlobal("window", {778 vi.stubGlobal("window", {
535 localStorage: {779 localStorage: {
536 getItem: (key: string) =>780 getItem: (key: string) => (key === CUSTOM_THEMES_STORAGE_KEY ? storedThemes : null),
537 key === CUSTOM_THEMES_STORAGE_KEY781 setItem,
538 ? JSON.stringify([
539 {
540 id: "aurora",
541 label: "Aurora",
542 appearance: "light",
543 colors: { canvas: "#f8fbff", futureRole: "#123456", accent: "not-a-color" },
544 variants: { light: { canvas: "#101827" } },
545 },
546 { id: "light", label: "Reserved", appearance: "light", colors: {} },
547 { id: "aurora", label: "Duplicate", appearance: "dark", colors: {} },
548 ])
549 : null,
550 },782 },
551 });783 });
552 invalidateCustomThemes();784 invalidateCustomThemes();
...@@ -555,11 +787,16 @@ describe("stored theme preferences", () => {...@@ -555,11 +787,16 @@ describe("stored theme preferences", () => {
555 expect(themes).toHaveLength(1);787 expect(themes).toHaveLength(1);
556 expect(themes[0]).toMatchObject({788 expect(themes[0]).toMatchObject({
557 id: "aurora",789 id: "aurora",
558 colors: { canvas: "#f8fbff", accent: getDefaultThemeColors("light").accent },790 colors: { canvas: canonical("#f8fbff"), accent: getDefaultThemeColors("light").accent },
791 });
792 expect(getThemeModes(themes[0]!)).toEqual(["light", "dark"]);
793 expect(getThemeColorsForMode(themes[0]!, "dark")?.canvas).toBe(canonical("rgb(16 24 39)"));
794 expect(setItem).not.toHaveBeenCalled();
795 expect(JSON.parse(storedThemes)[0].colors).toEqual({
796 canvas: "#f8fbff",
797 futureRole: "#123456",
798 accent: "not-a-color",
559 });799 });
560 // The variant shadowing the base appearance is dropped so the theme
561 // round-trips through parseThemeFile on export.
562 expect(getThemeModes(themes[0]!)).toEqual(["light"]);
563800
564 vi.unstubAllGlobals();801 vi.unstubAllGlobals();
565 invalidateCustomThemes();802 invalidateCustomThemes();
apps/web/src/themePalette.ts+379-213
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import "culori/css";
3import { converter, parse } from "culori/fn";
24
3export const T3_CHAT_THEME_ID = "t3-chat" as const;5export const T3_CHAT_THEME_ID = "t3-chat" as const;
4export const T3_CHAT_THEME_LABEL = "T3 Chat";6export const T3_CHAT_THEME_LABEL = "T3 Chat";
...@@ -129,7 +131,16 @@ const RESERVED_THEME_IDS = new Set([...@@ -129,7 +131,16 @@ const RESERVED_THEME_IDS = new Set([
129]);131]);
130132
131const customThemeListeners = new Set<() => void>();133const customThemeListeners = new Set<() => void>();
132let customThemesSnapshot: ReadonlyArray<ThemeDefinition> | null = null;134type CustomThemeLibrarySnapshot =
135 | Readonly<{
136 status: "ready";
137 storedThemes: ReadonlyArray<unknown>;
138 themes: ReadonlyArray<ThemeDefinition>;
139 }>
140 | Readonly<{ status: "unavailable"; reason: "malformed" }>
141 | Readonly<{ status: "unavailable"; reason: "storage-unavailable"; cause: unknown }>;
142
143let customThemeLibrarySnapshot: CustomThemeLibrarySnapshot | null = null;
133const themePreviewListeners = new Set<() => void>();144const themePreviewListeners = new Set<() => void>();
134let themePreviewSidebarArtwork: boolean | null = null;145let themePreviewSidebarArtwork: boolean | null = null;
135146
...@@ -157,10 +168,7 @@ function isThemeAppearance(value: unknown): value is ThemeAppearance {...@@ -157,10 +168,7 @@ function isThemeAppearance(value: unknown): value is ThemeAppearance {
157}168}
158169
159export function isThemeColor(value: unknown): value is string {170export function isThemeColor(value: unknown): value is string {
160 return (171 return typeof value === "string" && toCanonicalThemeColor(value) !== null;
161 typeof value === "string" &&
162 /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)
163 );
164}172}
165173
166function isThemeId(value: unknown): value is string {174function isThemeId(value: unknown): value is string {
...@@ -180,8 +188,9 @@ function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): Th...@@ -180,8 +188,9 @@ function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): Th
180 // Tolerate unknown roles and malformed values so themes saved by other188 // Tolerate unknown roles and malformed values so themes saved by other
181 // builds (for example one that adds a new role) keep their remaining colors.189 // builds (for example one that adds a new role) keep their remaining colors.
182 for (const [role, color] of Object.entries(value)) {190 for (const [role, color] of Object.entries(value)) {
183 if (THEME_COLOR_ROLE_SET.has(role) && isThemeColor(color)) {191 const normalized = toCanonicalThemeColor(color);
184 colors[role as ThemeColorRole] = color;192 if (THEME_COLOR_ROLE_SET.has(role) && normalized) {
193 colors[role as ThemeColorRole] = normalized;
185 }194 }
186 }195 }
187 return colors as ThemeColors;196 return colors as ThemeColors;
...@@ -226,26 +235,43 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {...@@ -226,26 +235,43 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
226 };235 };
227}236}
228237
229function readCustomThemesFromStorage(): ReadonlyArray<ThemeDefinition> {238function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {
230 if (typeof window === "undefined") return [];239 if (typeof window === "undefined") {
240 return { status: "ready", storedThemes: [], themes: [] };
241 }
231242
243 let raw: string | null;
232 try {244 try {
233 const raw = window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY);245 raw = window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY);
234 if (!raw) return [];246 } catch (cause) {
235 const parsed: unknown = JSON.parse(raw);247 return { status: "unavailable", reason: "storage-unavailable", cause };
236 if (!Array.isArray(parsed)) return [];248 }
237249 if (!raw) return { status: "ready", storedThemes: [], themes: [] };
238 const themes: ThemeDefinition[] = [];250
239 for (const value of parsed) {251 let parsed: unknown;
240 const theme = parseStoredTheme(value);252 try {
241 if (theme && !themes.some((existing) => existing.id === theme.id)) {253 parsed = JSON.parse(raw);
242 themes.push(theme);
243 }
244 }
245 return themes;
246 } catch {254 } catch {
247 return [];255 return { status: "unavailable", reason: "malformed" };
248 }256 }
257 if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" };
258
259 const themes: ThemeDefinition[] = [];
260 for (const value of parsed) {
261 const theme = parseStoredTheme(value);
262 if (theme && !themes.some((existing) => existing.id === theme.id)) {
263 themes.push(theme);
264 }
265 }
266
267 return { status: "ready", storedThemes: parsed, themes };
268}
269
270function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {
271 if (customThemeLibrarySnapshot === null) {
272 customThemeLibrarySnapshot = readCustomThemeLibrarySnapshot();
273 }
274 return customThemeLibrarySnapshot;
249}275}
250276
251function notifyCustomThemeListeners() {277function notifyCustomThemeListeners() {
...@@ -253,15 +279,13 @@ function notifyCustomThemeListeners() {...@@ -253,15 +279,13 @@ function notifyCustomThemeListeners() {
253}279}
254280
255export function invalidateCustomThemes() {281export function invalidateCustomThemes() {
256 customThemesSnapshot = null;282 customThemeLibrarySnapshot = null;
257 notifyCustomThemeListeners();283 notifyCustomThemeListeners();
258}284}
259285
260export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {286export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {
261 if (customThemesSnapshot === null) {287 const snapshot = getCustomThemeLibrarySnapshot();
262 customThemesSnapshot = readCustomThemesFromStorage();288 return snapshot.status === "ready" ? snapshot.themes : [];
263 }
264 return customThemesSnapshot;
265}289}
266290
267export function subscribeToCustomThemes(listener: () => void): () => void {291export function subscribeToCustomThemes(listener: () => void): () => void {
...@@ -470,7 +494,7 @@ const T3_CHAT_DARK_COLORS: ThemeColors = {...@@ -470,7 +494,7 @@ const T3_CHAT_DARK_COLORS: ThemeColors = {
470 * stock tokens (index.css) so a draft seeded from the default look paints the494 * stock tokens (index.css) so a draft seeded from the default look paints the
471 * pixels the user is already seeing. Alpha-bearing tokens are flattened over495 * pixels the user is already seeing. Alpha-bearing tokens are flattened over
472 * their real backdrops (canvas, or the sidebar for its rows) because theme496 * their real backdrops (canvas, or the sidebar for its rows) because theme
473 * colors must be opaque hex.497 * colors are stored as opaque OKLCH tokens.
474 */498 */
475const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = {499const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = {
476 canvas: "#fcfcfc",500 canvas: "#fcfcfc",
...@@ -599,7 +623,10 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = {...@@ -599,7 +623,10 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = {
599 * files.623 * files.
600 */624 */
601export function getStandardThemeColors(appearance: ThemeAppearance): ThemeColors {625export function getStandardThemeColors(appearance: ThemeAppearance): ThemeColors {
602 return appearance === "dark" ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS;626 if (appearance === "dark") {
627 return (standardDarkThemeColors ??= decodeThemeColors(T3_CODE_DARK_THEME_COLORS));
628 }
629 return (standardLightThemeColors ??= decodeThemeColors(T3_CODE_LIGHT_THEME_COLORS));
603}630}
604631
605type ThemeRgbColor = {632type ThemeRgbColor = {
...@@ -614,34 +641,81 @@ type ThemeHslColor = {...@@ -614,34 +641,81 @@ type ThemeHslColor = {
614 l: number;641 l: number;
615};642};
616643
644type ThemeOklch = { L: number; C: number; h: number };
645type ParsedThemeColor = { color: ThemeOklch; alpha: number };
646
647let standardLightThemeColors: ThemeColors | undefined;
648let standardDarkThemeColors: ThemeColors | undefined;
649
617const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 };650const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 };
618const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 };651const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 };
619const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 };652const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 };
620const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 };653const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 };
621654
622function parseThemeRgbColor(value: string, fallback: ThemeRgbColor): ThemeRgbColor {655const convertToOklch = converter("oklch");
623 const match = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);656
624 if (!match) return fallback;657function parseThemeColor(value: unknown): ParsedThemeColor | null {
625658 if (typeof value !== "string") return null;
626 const raw = match[1];659 const input = value.trim();
627 if (!raw) return fallback;660 const parsed = parse(input);
628 const hex =661 if (!parsed) return null;
629 raw.length <= 4662 const color = convertToOklch(parsed);
630 ? raw663 const lightness = color.l ?? 0;
631 .slice(0, 3)664 const chroma = color.c ?? 0;
632 .split("")665 const hue = color.h ?? 0;
633 .map((part) => part.repeat(2))666 // CSS missing components behave as zero outside interpolation. Culori omits
634 .join("")667 // a `none` alpha from its parsed object, so distinguish it from omitted alpha.
635 : raw.slice(0, 6);668 const alpha = /\/\s*none\s*\)$/i.test(input) ? 0 : (color.alpha ?? 1);
636 if (hex.length !== 6) return fallback;669 if (![lightness, chroma, hue, alpha].every(Number.isFinite)) return null;
637
638 return {670 return {
639 r: Number.parseInt(hex.slice(0, 2), 16),671 color: {
640 g: Number.parseInt(hex.slice(2, 4), 16),672 L: Math.min(1, Math.max(0, lightness)),
641 b: Number.parseInt(hex.slice(4, 6), 16),673 C: Math.max(0, chroma),
674 h: hue,
675 },
676 alpha: Math.min(1, Math.max(0, alpha)),
642 };677 };
643}678}
644679
680function formatThemeColorNumber(value: number, precision: number): string {
681 const rounded = Math.abs(value) < 10 ** -precision / 2 ? 0 : value;
682 return rounded.toFixed(precision).replace(/(?:\.0+|(?:(\.[0-9]*?)0+))$/, "$1");
683}
684
685function formatOklchThemeColor(color: ThemeOklch, alpha = 1): string {
686 const normalizedHue = color.C < 0.0000005 ? 0 : ((color.h % 360) + 360) % 360;
687 const body = `${formatThemeColorNumber(color.L, 6)} ${formatThemeColorNumber(color.C, 6)} ${formatThemeColorNumber(normalizedHue, 3)}`;
688 return alpha < 1 ? `oklch(${body} / ${formatThemeColorNumber(alpha, 4)})` : `oklch(${body})`;
689}
690
691/**
692 * Decode a literal CSS color into the runtime's canonical OKLCH form. Stored
693 * values use this path in memory without mutating localStorage.
694 */
695export function toCanonicalThemeColor(value: unknown): string | null {
696 const parsed = parseThemeColor(value);
697 return parsed ? formatOklchThemeColor(parsed.color, parsed.alpha) : null;
698}
699
700/** Convert a runtime theme color for hex-only editor and import adapters. */
701export function themeColorToHex(value: string): string | null {
702 const color = parseThemeColor(value);
703 const parsed = color ? { rgb: themeOklchToRgb(color.color), alpha: color.alpha } : null;
704 if (!parsed) return null;
705
706 const opaque = themeRgbToHexColor(parsed.rgb);
707 if (parsed.alpha >= 1) return opaque;
708 const alpha = Math.round(parsed.alpha * 255)
709 .toString(16)
710 .padStart(2, "0");
711 return `${opaque}${alpha}`;
712}
713
714function parseThemeRgbColor(value: string, fallback: ThemeRgbColor): ThemeRgbColor {
715 const parsed = parseThemeColor(value);
716 return parsed ? themeOklchToRgb(parsed.color) : fallback;
717}
718
645function themeRgbToHexColor(color: ThemeRgbColor): string {719function themeRgbToHexColor(color: ThemeRgbColor): string {
646 return `#${[color.r, color.g, color.b]720 return `#${[color.r, color.g, color.b]
647 .map((channel) =>721 .map((channel) =>
...@@ -652,6 +726,41 @@ function themeRgbToHexColor(color: ThemeRgbColor): string {...@@ -652,6 +726,41 @@ function themeRgbToHexColor(color: ThemeRgbColor): string {
652 .join("")}`;726 .join("")}`;
653}727}
654728
729function themeRgbToThemeColor(color: ThemeRgbColor): string {
730 return formatOklchThemeColor(themeRgbToOklch(color));
731}
732
733function decodeThemeColors(colors: ThemeColors): ThemeColors {
734 return Object.fromEntries(
735 THEME_COLOR_ROLES.map((role) => {
736 const color = toCanonicalThemeColor(colors[role]);
737 if (!color) {
738 throw new Error(
739 `The color for "${role}" must be a literal CSS color such as oklch(0.62 0.2 280).`,
740 );
741 }
742 return [role, color];
743 }),
744 ) as Record<ThemeColorRole, string>;
745}
746
747function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition {
748 return {
749 ...theme,
750 colors: decodeThemeColors(theme.colors),
751 ...(theme.variants
752 ? {
753 variants: Object.fromEntries(
754 Object.entries(theme.variants).map(([appearance, colors]) => [
755 appearance,
756 decodeThemeColors(colors),
757 ]),
758 ) as ThemeVariants,
759 }
760 : {}),
761 };
762}
763
655function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor {764function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor {
656 const red = color.r / 255;765 const red = color.r / 255;
657 const green = color.g / 255;766 const green = color.g / 255;
...@@ -717,10 +826,6 @@ function themeRelativeLuminance(color: ThemeRgbColor): number {...@@ -717,10 +826,6 @@ function themeRelativeLuminance(color: ThemeRgbColor): number {
717826
718// ---------------------------------------------------------------------------827// ---------------------------------------------------------------------------
719// Vivid palette engine: perceptual (OKLCH) derivation for user-created themes.828// Vivid palette engine: perceptual (OKLCH) derivation for user-created themes.
720// Built-in themes keep the legacy derivation so their shipped palettes and the
721// boot splash copies stay byte-identical.
722
723type ThemeOklch = { L: number; C: number; h: number };
724829
725function srgbChannelToLinear(channel: number): number {830function srgbChannelToLinear(channel: number): number {
726 const c = channel / 255;831 const c = channel / 255;
...@@ -759,24 +864,30 @@ function oklchToRgbUnclamped({ L, C, h }: ThemeOklch): { r: number; g: number; b...@@ -759,24 +864,30 @@ function oklchToRgbUnclamped({ L, C, h }: ThemeOklch): { r: number; g: number; b
759 };864 };
760}865}
761866
762/** Convert to sRGB, walking chroma toward grey until the color is in gamut. */867/** Find the greatest chroma along the same lightness and hue that fits in sRGB. */
763function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor {868function mapThemeOklchToSrgbGamut(color: ThemeOklch): ThemeOklch {
764 let { C } = color;869 const isInGamut = (C: number) => {
765 for (let step = 0; step < 12; step += 1) {
766 const linear = oklchToRgbUnclamped({ ...color, C });870 const linear = oklchToRgbUnclamped({ ...color, C });
767 const inGamut = [linear.r, linear.g, linear.b].every(871 return [linear.r, linear.g, linear.b].every(
768 (channel) => channel >= -0.0001 && channel <= 1.0001,872 (channel) => channel >= -0.0001 && channel <= 1.0001,
769 );873 );
770 if (inGamut) {874 };
771 return {875 if (isInGamut(color.C)) return color;
772 r: linearChannelToSrgb(linear.r),876
773 g: linearChannelToSrgb(linear.g),877 let low = 0;
774 b: linearChannelToSrgb(linear.b),878 let high = color.C;
775 };879 const steps = Math.max(1, Math.ceil(Math.log2(Math.max(color.C, 0.000001) / 0.000001)));
776 }880 for (let step = 0; step < steps; step += 1) {
777 C *= 0.82;881 const mid = (low + high) / 2;
882 if (isInGamut(mid)) low = mid;
883 else high = mid;
778 }884 }
779 const linear = oklchToRgbUnclamped({ ...color, C: 0 });885 return { ...color, C: low };
886}
887
888/** Convert to sRGB after applying the palette engine's gamut mapping. */
889function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor {
890 const linear = oklchToRgbUnclamped(mapThemeOklchToSrgbGamut(color));
780 return {891 return {
781 r: linearChannelToSrgb(linear.r),892 r: linearChannelToSrgb(linear.r),
782 g: linearChannelToSrgb(linear.g),893 g: linearChannelToSrgb(linear.g),
...@@ -784,6 +895,10 @@ function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor {...@@ -784,6 +895,10 @@ function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor {
784 };895 };
785}896}
786897
898function themeOklchToThemeColor(color: ThemeOklch): string {
899 return formatOklchThemeColor(mapThemeOklchToSrgbGamut(color));
900}
901
787/** Binary-search the lightness that reaches the contrast target against a background. */902/** Binary-search the lightness that reaches the contrast target against a background. */
788function solveOklchLightness(903function solveOklchLightness(
789 base: ThemeOklch,904 base: ThemeOklch,
...@@ -856,26 +971,24 @@ function standardStatusColors(canvas: ThemeRgbColor): {...@@ -856,26 +971,24 @@ function standardStatusColors(canvas: ThemeRgbColor): {
856 // tinted one they can fall just short, so lightness is nudged until the971 // tinted one they can fall just short, so lightness is nudged until the
857 // pair clears 4.5 while the hue stays standard.972 // pair clears 4.5 while the hue stays standard.
858 const readableOn = (foreground: string, surface: ThemeRgbColor) =>973 const readableOn = (foreground: string, surface: ThemeRgbColor) =>
859 themeRgbToHexColor(974 themeOklchToThemeColor(
860 themeOklchToRgb(975 solveOklchLightness(
861 solveOklchLightness(976 themeRgbToOklch(parseThemeRgbColor(foreground, canvas)),
862 themeRgbToOklch(parseThemeRgbColor(foreground, canvas)),977 surface,
863 surface,978 // Leave a little headroom for browser color conversion at render time.
864 // A hair above 4.5: the solve happens in OKLCH and the result is979 4.6,
865 // quantized to 8-bit hex, which can shave the last hundredth off.980 appearance === "dark" ? "lighter" : "darker",
866 4.6,
867 appearance === "dark" ? "lighter" : "darker",
868 ),
869 ),981 ),
870 );982 );
871 const errorSurface = surfaceOf(standard.error);983 const errorSurface = surfaceOf(standard.error);
872 const warningSurface = surfaceOf(standard.warning);984 const warningSurface = surfaceOf(standard.warning);
873 return {985 return {
874 ...standard,986 error: toCanonicalThemeColor(standard.error)!,
875 errorForeground: readableOn(standard.errorForeground, errorSurface),987 errorForeground: readableOn(standard.errorForeground, errorSurface),
876 errorSurface: themeRgbToHexColor(errorSurface),988 errorSurface: themeRgbToThemeColor(errorSurface),
989 warning: toCanonicalThemeColor(standard.warning)!,
877 warningForeground: readableOn(standard.warningForeground, warningSurface),990 warningForeground: readableOn(standard.warningForeground, warningSurface),
878 warningSurface: themeRgbToHexColor(warningSurface),991 warningSurface: themeRgbToThemeColor(warningSurface),
879 };992 };
880}993}
881994
...@@ -912,7 +1025,7 @@ export function createVividThemeColors(...@@ -912,7 +1025,7 @@ export function createVividThemeColors(
912 C: chroma,1025 C: chroma,
913 h: hue,1026 h: hue,
914 });1027 });
915 const hex = (color: ThemeOklch) => themeRgbToHexColor(themeOklchToRgb(color));1028 const themeColor = (color: ThemeOklch) => themeOklchToThemeColor(color);
9161029
917 // Text carries a whisper of the accent hue instead of falling back to a1030 // Text carries a whisper of the accent hue instead of falling back to a
918 // fixed foreground, and is solved to WCAG AAA against the canvas.1031 // fixed foreground, and is solved to WCAG AAA against the canvas.
...@@ -956,8 +1069,8 @@ export function createVividThemeColors(...@@ -956,8 +1069,8 @@ export function createVividThemeColors(
956 const updateSurface = surfaceAt(dark ? 0.14 : 0.09, Math.min(0.12, accent.C * 0.55));1069 const updateSurface = surfaceAt(dark ? 0.14 : 0.09, Math.min(0.12, accent.C * 0.55));
9571070
958 const foregroundOn = (surfaceRgb: ThemeRgbColor): string =>1071 const foregroundOn = (surfaceRgb: ThemeRgbColor): string =>
959 themeRgbToHexColor(1072 themeOklchToThemeColor(
960 themeOklchToRgb(solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker")),1073 solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker"),
961 );1074 );
962 const mutedForeground = foregroundOn(mutedRgb);1075 const mutedForeground = foregroundOn(mutedRgb);
963 const placeholder = foregroundOn(surfaceRaisedRgb);1076 const placeholder = foregroundOn(surfaceRaisedRgb);
...@@ -967,58 +1080,58 @@ export function createVividThemeColors(...@@ -967,58 +1080,58 @@ export function createVividThemeColors(
967 return {1080 return {
968 ...defaults,1081 ...defaults,
969 ...standardStatusColors(canvasRgb),1082 ...standardStatusColors(canvasRgb),
970 canvas: themeRgbToHexColor(canvasRgb),1083 canvas: themeRgbToThemeColor(canvasRgb),
971 // The top bar shares the canvas so the main panel reads as one surface.1084 // The top bar shares the canvas so the main panel reads as one surface.
972 chrome: themeRgbToHexColor(canvasRgb),1085 chrome: themeRgbToThemeColor(canvasRgb),
973 toolbar: themeRgbToHexColor(canvasRgb),1086 toolbar: themeRgbToThemeColor(canvasRgb),
974 toolbarForeground: themeRgbToHexColor(textRgb),1087 toolbarForeground: themeRgbToThemeColor(textRgb),
975 toolbarBorder: hex(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.08, accent.C * 0.4))),1088 toolbarBorder: themeColor(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.08, accent.C * 0.4))),
976 toolbarControl: hex(surfaceAt(dark ? 0.09 : 0.05, tintC * 1.3)),1089 toolbarControl: themeColor(surfaceAt(dark ? 0.09 : 0.05, tintC * 1.3)),
977 toolbarControlForeground: themeRgbToHexColor(textRgb),1090 toolbarControlForeground: themeRgbToThemeColor(textRgb),
978 toolbarControlHover: hex(surfaceAt(dark ? 0.14 : 0.09, tintC * 1.6)),1091 toolbarControlHover: themeColor(surfaceAt(dark ? 0.14 : 0.09, tintC * 1.6)),
979 surface: hex(surface),1092 surface: themeColor(surface),
980 surfaceRaised: hex(surfaceRaised),1093 surfaceRaised: themeColor(surfaceRaised),
981 surfaceOverlay: hex(surfaceOverlay),1094 surfaceOverlay: themeColor(surfaceOverlay),
982 text: themeRgbToHexColor(textRgb),1095 text: themeRgbToThemeColor(textRgb),
983 textMuted: themeRgbToHexColor(textMutedRgb),1096 textMuted: themeRgbToThemeColor(textMutedRgb),
984 border: hex(border),1097 border: themeColor(border),
985 input: hex(input),1098 input: themeColor(input),
986 focus: themeRgbToHexColor(accentRgb),1099 focus: themeRgbToThemeColor(accentRgb),
987 accent: themeRgbToHexColor(accentRgb),1100 accent: themeRgbToThemeColor(accentRgb),
988 accentForeground: themeRgbToHexColor(accentForeground),1101 accentForeground: themeRgbToThemeColor(accentForeground),
989 secondary: hex(secondary),1102 secondary: themeColor(secondary),
990 secondaryForeground: foregroundOn(secondaryRgb),1103 secondaryForeground: foregroundOn(secondaryRgb),
991 muted: hex(muted),1104 muted: themeColor(muted),
992 mutedForeground,1105 mutedForeground,
993 placeholder,1106 placeholder,
994 secondaryLabel: themeRgbToHexColor(textMutedRgb),1107 secondaryLabel: themeRgbToThemeColor(textMutedRgb),
995 iconMuted: themeRgbToHexColor(textMutedRgb),1108 iconMuted: themeRgbToThemeColor(textMutedRgb),
996 update: themeRgbToHexColor(accentRgb),1109 update: themeRgbToThemeColor(accentRgb),
997 updateForeground: foregroundOn(themeOklchToRgb(updateSurface)),1110 updateForeground: foregroundOn(themeOklchToRgb(updateSurface)),
998 updateSurface: hex(updateSurface),1111 updateSurface: themeColor(updateSurface),
999 accentSurface: hex(accentSurface),1112 accentSurface: themeColor(accentSurface),
1000 accentSurfaceForeground: foregroundOn(accentSurfaceRgb),1113 accentSurfaceForeground: foregroundOn(accentSurfaceRgb),
1001 messageSurface: hex(messageSurface),1114 messageSurface: themeColor(messageSurface),
1002 messageForeground: foregroundOn(messageSurfaceRgb),1115 messageForeground: foregroundOn(messageSurfaceRgb),
1003 messageAction: themeRgbToHexColor(actionRgb),1116 messageAction: themeRgbToThemeColor(actionRgb),
1004 messageActionForeground: themeRgbToHexColor(actionForeground),1117 messageActionForeground: themeRgbToThemeColor(actionForeground),
1005 messageActionHover: hex(actionHover),1118 messageActionHover: themeColor(actionHover),
1006 codeBackground: hex(codeBackground),1119 codeBackground: themeColor(codeBackground),
1007 codeForeground: themeRgbToHexColor(textRgb),1120 codeForeground: themeRgbToThemeColor(textRgb),
1008 sidebar: hex(sidebar),1121 sidebar: themeColor(sidebar),
1009 sidebarForeground: foregroundOn(sidebarRgb),1122 sidebarForeground: foregroundOn(sidebarRgb),
1010 sidebarMutedForeground: themeRgbToHexColor(standardMutedThemeText(sidebarRgb, textRgb)),1123 sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebarRgb, textRgb)),
1011 sidebarControlSurface: hex(surfaceAt(dark ? 0.1 : 0.07, tintC * 1.5)),1124 sidebarControlSurface: themeColor(surfaceAt(dark ? 0.1 : 0.07, tintC * 1.5)),
1012 sidebarRowHover: hex(surfaceAt(dark ? 0.08 : 0.06, Math.min(0.08, accent.C * 0.45))),1125 sidebarRowHover: themeColor(surfaceAt(dark ? 0.08 : 0.06, Math.min(0.08, accent.C * 0.45))),
1013 sidebarRowActive: hex(surfaceAt(dark ? 0.12 : 0.09, Math.min(0.1, accent.C * 0.55))),1126 sidebarRowActive: themeColor(surfaceAt(dark ? 0.12 : 0.09, Math.min(0.1, accent.C * 0.55))),
1014 sidebarRowSelected: hex(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.11, accent.C * 0.6))),1127 sidebarRowSelected: themeColor(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.11, accent.C * 0.6))),
1015 sidebarBorder: hex(surfaceAt(dark ? 0.17 : 0.12, Math.min(0.08, accent.C * 0.4))),1128 sidebarBorder: themeColor(surfaceAt(dark ? 0.17 : 0.12, Math.min(0.08, accent.C * 0.4))),
1016 terminalBackground: themeRgbToHexColor(canvasRgb),1129 terminalBackground: themeRgbToThemeColor(canvasRgb),
1017 terminalForeground: themeRgbToHexColor(textRgb),1130 terminalForeground: themeRgbToThemeColor(textRgb),
1018 terminalCursor: themeRgbToHexColor(accentRgb),1131 terminalCursor: themeRgbToThemeColor(accentRgb),
1019 terminalSelection: hex(surfaceAt(dark ? 0.18 : 0.12, Math.min(0.12, accent.C * 0.55))),1132 terminalSelection: themeColor(surfaceAt(dark ? 0.18 : 0.12, Math.min(0.12, accent.C * 0.55))),
1020 terminalScrollbar: hex(surfaceAt(dark ? 0.22 : 0.16, tintC)),1133 terminalScrollbar: themeColor(surfaceAt(dark ? 0.22 : 0.16, tintC)),
1021 terminalScrollbarHover: hex(surfaceAt(dark ? 0.3 : 0.22, tintC)),1134 terminalScrollbarHover: themeColor(surfaceAt(dark ? 0.3 : 0.22, tintC)),
1022 };1135 };
1023}1136}
10241137
...@@ -1127,8 +1240,7 @@ function managedThemeAccent(...@@ -1127,8 +1240,7 @@ function managedThemeAccent(
1127 const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness });1240 const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness });
1128 return { color, lightness, contrast: themeContrastRatio(color, background) };1241 return { color, lightness, contrast: themeContrastRatio(color, background) };
1129 });1242 });
1130 // Leave a little room for rounding when the generated RGB values become a1243 // Leave a little room for browser color conversion at render time.
1131 // six-digit hex token.
1132 const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7);1244 const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7);
1133 const pool = readableCandidates.length > 0 ? readableCandidates : candidates;1245 const pool = readableCandidates.length > 0 ? readableCandidates : candidates;
11341246
...@@ -1210,80 +1322,80 @@ export function createManagedThemeColors(...@@ -1210,80 +1322,80 @@ export function createManagedThemeColors(
1210 return {1322 return {
1211 ...defaults,1323 ...defaults,
1212 ...standardStatusColors(canvas),1324 ...standardStatusColors(canvas),
1213 update: themeRgbToHexColor(accent),1325 update: themeRgbToThemeColor(accent),
1214 updateForeground: themeRgbToHexColor(updateForeground),1326 updateForeground: themeRgbToThemeColor(updateForeground),
1215 updateSurface: themeRgbToHexColor(updateSurface),1327 updateSurface: themeRgbToThemeColor(updateSurface),
1216 canvas: themeRgbToHexColor(canvas),1328 canvas: themeRgbToThemeColor(canvas),
1217 chrome: themeRgbToHexColor(chrome),1329 chrome: themeRgbToThemeColor(chrome),
1218 toolbar: themeRgbToHexColor(chrome),1330 toolbar: themeRgbToThemeColor(chrome),
1219 toolbarForeground: themeRgbToHexColor(text),1331 toolbarForeground: themeRgbToThemeColor(text),
1220 toolbarBorder: themeRgbToHexColor(toolbarBorder),1332 toolbarBorder: themeRgbToThemeColor(toolbarBorder),
1221 toolbarControl: themeRgbToHexColor(toolbarControl),1333 toolbarControl: themeRgbToThemeColor(toolbarControl),
1222 toolbarControlForeground: themeRgbToHexColor(text),1334 toolbarControlForeground: themeRgbToThemeColor(text),
1223 toolbarControlHover: themeRgbToHexColor(accentSurface),1335 toolbarControlHover: themeRgbToThemeColor(accentSurface),
1224 surface: themeRgbToHexColor(canvas),1336 surface: themeRgbToThemeColor(canvas),
1225 surfaceRaised: themeRgbToHexColor(surfaceRaised),1337 surfaceRaised: themeRgbToThemeColor(surfaceRaised),
1226 surfaceOverlay: themeRgbToHexColor(surfaceOverlay),1338 surfaceOverlay: themeRgbToThemeColor(surfaceOverlay),
1227 text: themeRgbToHexColor(text),1339 text: themeRgbToThemeColor(text),
1228 textMuted: themeRgbToHexColor(textMuted),1340 textMuted: themeRgbToThemeColor(textMuted),
1229 // Borders blend through the accent before lightening so control chrome1341 // Borders blend through the accent before lightening so control chrome
1230 // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1)1342 // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1)
1231 // instead of flattening to grey.1343 // instead of flattening to grey.
1232 border: themeRgbToHexColor(1344 border: themeRgbToThemeColor(
1233 mixThemeRgbColors(1345 mixThemeRgbColors(
1234 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1),1346 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1),
1235 text,1347 text,
1236 0.1,1348 0.1,
1237 ),1349 ),
1238 ),1350 ),
1239 input: themeRgbToHexColor(1351 input: themeRgbToThemeColor(
1240 mixThemeRgbColors(1352 mixThemeRgbColors(
1241 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14),1353 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14),
1242 text,1354 text,
1243 appearance === "dark" ? 0.14 : 0.13,1355 appearance === "dark" ? 0.14 : 0.13,
1244 ),1356 ),
1245 ),1357 ),
1246 focus: themeRgbToHexColor(accent),1358 focus: themeRgbToThemeColor(accent),
1247 accent: themeRgbToHexColor(accent),1359 accent: themeRgbToThemeColor(accent),
1248 accentForeground: themeRgbToHexColor(accentForeground),1360 accentForeground: themeRgbToThemeColor(accentForeground),
1249 secondary: themeRgbToHexColor(secondary),1361 secondary: themeRgbToThemeColor(secondary),
1250 secondaryForeground: themeRgbToHexColor(readableThemeForeground(secondary)),1362 secondaryForeground: themeRgbToThemeColor(readableThemeForeground(secondary)),
1251 muted: themeRgbToHexColor(muted),1363 muted: themeRgbToThemeColor(muted),
1252 mutedForeground: themeRgbToHexColor(mutedForeground),1364 mutedForeground: themeRgbToThemeColor(mutedForeground),
1253 placeholder: themeRgbToHexColor(placeholder),1365 placeholder: themeRgbToThemeColor(placeholder),
1254 secondaryLabel: themeRgbToHexColor(textMuted),1366 secondaryLabel: themeRgbToThemeColor(textMuted),
1255 iconMuted: themeRgbToHexColor(textMuted),1367 iconMuted: themeRgbToThemeColor(textMuted),
1256 accentSurface: themeRgbToHexColor(accentSurface),1368 accentSurface: themeRgbToThemeColor(accentSurface),
1257 accentSurfaceForeground: themeRgbToHexColor(readableThemeForeground(accentSurface)),1369 accentSurfaceForeground: themeRgbToThemeColor(readableThemeForeground(accentSurface)),
1258 messageSurface: themeRgbToHexColor(messageSurface),1370 messageSurface: themeRgbToThemeColor(messageSurface),
1259 messageForeground: themeRgbToHexColor(readableThemeForeground(messageSurface)),1371 messageForeground: themeRgbToThemeColor(readableThemeForeground(messageSurface)),
1260 messageAction: themeRgbToHexColor(accent),1372 messageAction: themeRgbToThemeColor(accent),
1261 messageActionForeground: themeRgbToHexColor(accentForeground),1373 messageActionForeground: themeRgbToThemeColor(accentForeground),
1262 messageActionHover: themeRgbToHexColor(messageActionHover),1374 messageActionHover: themeRgbToThemeColor(messageActionHover),
1263 codeBackground: themeRgbToHexColor(codeBackground),1375 codeBackground: themeRgbToThemeColor(codeBackground),
1264 codeForeground: themeRgbToHexColor(readableThemeForeground(codeBackground)),1376 codeForeground: themeRgbToThemeColor(readableThemeForeground(codeBackground)),
1265 sidebar: themeRgbToHexColor(sidebar),1377 sidebar: themeRgbToThemeColor(sidebar),
1266 sidebarForeground: themeRgbToHexColor(readableThemeForeground(sidebar)),1378 sidebarForeground: themeRgbToThemeColor(readableThemeForeground(sidebar)),
1267 sidebarMutedForeground: themeRgbToHexColor(standardMutedThemeText(sidebar, text)),1379 sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebar, text)),
1268 sidebarControlSurface: themeRgbToHexColor(1380 sidebarControlSurface: themeRgbToThemeColor(
1269 mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08),1381 mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08),
1270 ),1382 ),
1271 sidebarRowHover: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.12)),1383 sidebarRowHover: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.12)),
1272 sidebarRowActive: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.2)),1384 sidebarRowActive: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.2)),
1273 sidebarRowSelected: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.24)),1385 sidebarRowSelected: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.24)),
1274 sidebarBorder: themeRgbToHexColor(1386 sidebarBorder: themeRgbToThemeColor(
1275 mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12),1387 mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12),
1276 ),1388 ),
1277 terminalBackground: themeRgbToHexColor(terminalBackground),1389 terminalBackground: themeRgbToThemeColor(terminalBackground),
1278 terminalForeground: themeRgbToHexColor(readableThemeForeground(terminalBackground)),1390 terminalForeground: themeRgbToThemeColor(readableThemeForeground(terminalBackground)),
1279 terminalCursor: themeRgbToHexColor(accent),1391 terminalCursor: themeRgbToThemeColor(accent),
1280 terminalSelection: themeRgbToHexColor(1392 terminalSelection: themeRgbToThemeColor(
1281 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18),1393 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18),
1282 ),1394 ),
1283 terminalScrollbar: themeRgbToHexColor(1395 terminalScrollbar: themeRgbToThemeColor(
1284 mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22),1396 mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22),
1285 ),1397 ),
1286 terminalScrollbarHover: themeRgbToHexColor(1398 terminalScrollbarHover: themeRgbToThemeColor(
1287 mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32),1399 mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32),
1288 ),1400 ),
1289 };1401 };
...@@ -1293,16 +1405,16 @@ export const T3_CHAT_THEME: ThemeDefinition = {...@@ -1293,16 +1405,16 @@ export const T3_CHAT_THEME: ThemeDefinition = {
1293 id: T3_CHAT_THEME_ID,1405 id: T3_CHAT_THEME_ID,
1294 label: T3_CHAT_THEME_LABEL,1406 label: T3_CHAT_THEME_LABEL,
1295 appearance: "light",1407 appearance: "light",
1296 colors: T3_CHAT_LIGHT_COLORS,1408 colors: decodeThemeColors(T3_CHAT_LIGHT_COLORS),
1297 variants: {1409 variants: {
1298 dark: T3_CHAT_DARK_COLORS,1410 dark: decodeThemeColors(T3_CHAT_DARK_COLORS),
1299 },1411 },
1300 sidebarArtwork: true,1412 sidebarArtwork: true,
1301};1413};
13021414
1303/** Theme-file defaults follow the flagship palette for the requested mode. */1415/** Theme-file defaults follow the flagship palette for the requested mode. */
1304export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors {1416export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors {
1305 return appearance === "dark" ? T3_CHAT_DARK_COLORS : T3_CHAT_LIGHT_COLORS;1417 return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors;
1306}1418}
13071419
1308/**1420/**
...@@ -1320,9 +1432,9 @@ function themeActionColors(...@@ -1320,9 +1432,9 @@ function themeActionColors(
1320 ? THEME_BLACK_FOREGROUND1432 ? THEME_BLACK_FOREGROUND
1321 : THEME_WHITE_FOREGROUND;1433 : THEME_WHITE_FOREGROUND;
1322 return {1434 return {
1323 messageAction: action,1435 messageAction: toCanonicalThemeColor(action) ?? themeRgbToThemeColor(rgb),
1324 messageActionForeground: themeRgbToHexColor(foreground),1436 messageActionForeground: themeRgbToThemeColor(foreground),
1325 messageActionHover: themeRgbToHexColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)),1437 messageActionHover: themeRgbToThemeColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)),
1326 };1438 };
1327}1439}
13281440
...@@ -1452,39 +1564,72 @@ export function themeIdFromName(name: string): string {...@@ -1452,39 +1564,72 @@ export function themeIdFromName(name: string): string {
14521564
1453export class ThemeLibraryStorageError extends Schema.TaggedErrorClass<ThemeLibraryStorageError>()(1565export class ThemeLibraryStorageError extends Schema.TaggedErrorClass<ThemeLibraryStorageError>()(
1454 "ThemeLibraryStorageError",1566 "ThemeLibraryStorageError",
1455 { storageKey: Schema.String, cause: Schema.Defect() },1567 {
1568 storageKey: Schema.String,
1569 operation: Schema.Literals(["read", "write"]),
1570 reason: Schema.Literals(["malformed", "storage-unavailable"]),
1571 cause: Schema.optional(Schema.Defect()),
1572 },
1456) {1573) {
1457 override get message(): string {1574 override get message(): string {
1458 return `Failed to write the theme library to ${this.storageKey}.`;1575 const direction = this.operation === "read" ? "from" : "to";
1576 return `Failed to ${this.operation} the theme library ${direction} ${this.storageKey}.`;
1459 }1577 }
1460}1578}
14611579
1462export const isThemeLibraryStorageError = Schema.is(ThemeLibraryStorageError);1580export const isThemeLibraryStorageError = Schema.is(ThemeLibraryStorageError);
14631581
1464function saveCustomThemes(themes: ReadonlyArray<ThemeDefinition>): void {1582function saveCustomThemes(
1583 storedThemes: ReadonlyArray<unknown>,
1584 themes: ReadonlyArray<ThemeDefinition>,
1585): void {
1465 if (typeof window === "undefined") return;1586 if (typeof window === "undefined") return;
1466 try {1587 try {
1467 window.localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify(themes));1588 window.localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify(storedThemes));
1468 customThemesSnapshot = themes;1589 customThemeLibrarySnapshot = { status: "ready", storedThemes, themes };
1469 } catch (cause) {1590 } catch (cause) {
1470 throw new ThemeLibraryStorageError({ storageKey: CUSTOM_THEMES_STORAGE_KEY, cause });1591 throw new ThemeLibraryStorageError({
1592 storageKey: CUSTOM_THEMES_STORAGE_KEY,
1593 operation: "write",
1594 reason: "storage-unavailable",
1595 cause,
1596 });
1471 }1597 }
1472 notifyCustomThemeListeners();1598 notifyCustomThemeListeners();
1473}1599}
14741600
1601function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> {
1602 const snapshot = getCustomThemeLibrarySnapshot();
1603 if (snapshot.status === "unavailable") {
1604 throw new ThemeLibraryStorageError({
1605 storageKey: CUSTOM_THEMES_STORAGE_KEY,
1606 operation: "read",
1607 reason: snapshot.reason,
1608 ...("cause" in snapshot ? { cause: snapshot.cause } : {}),
1609 });
1610 }
1611 return snapshot;
1612}
1613
1614function storedThemeHasId(storedTheme: unknown, themeId: string): boolean {
1615 return isRecord(storedTheme) && storedTheme.id === themeId;
1616}
1617
1475export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition {1618export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition {
1476 if (RESERVED_THEME_IDS.has(theme.id)) {1619 if (RESERVED_THEME_IDS.has(theme.id)) {
1477 throw new Error(`The theme id "${theme.id}" is reserved.`);1620 throw new Error(`The theme id "${theme.id}" is reserved.`);
1478 }1621 }
1622 const library = getWritableCustomThemeLibrary();
1479 if (1623 if (
1480 [...BUILT_IN_THEME_DEFINITIONS, ...getCustomThemes()].some(1624 BUILT_IN_THEME_DEFINITIONS.some((existing) => existing.id === theme.id) ||
1481 (existing) => existing.id === theme.id,1625 library.storedThemes.some((storedTheme) => storedThemeHasId(storedTheme, theme.id))
1482 )
1483 ) {1626 ) {
1484 throw new Error(`A theme named "${theme.label}" is already installed.`);1627 throw new Error(`A theme named "${theme.label}" is already installed.`);
1485 }1628 }
1486 saveCustomThemes([...getCustomThemes(), theme]);1629 const canonicalTheme = canonicalizeThemeDefinition(theme);
1487 return theme;1630 const themes = [...library.themes, canonicalTheme];
1631 saveCustomThemes([...library.storedThemes, canonicalTheme], themes);
1632 return canonicalTheme;
1488}1633}
14891634
1490export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition {1635export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition {
...@@ -1492,22 +1637,39 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition {...@@ -1492,22 +1637,39 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition {
1492 throw new Error(`The theme id "${theme.id}" is reserved.`);1637 throw new Error(`The theme id "${theme.id}" is reserved.`);
1493 }1638 }
14941639
1495 const themes = getCustomThemes();1640 const library = getWritableCustomThemeLibrary();
1641 const themes = library.themes;
1496 const themeIndex = themes.findIndex((existing) => existing.id === theme.id);1642 const themeIndex = themes.findIndex((existing) => existing.id === theme.id);
1497 if (themeIndex === -1) {1643 if (themeIndex === -1) {
1498 throw new Error(`The theme "${theme.label}" is not installed.`);1644 throw new Error(`The theme "${theme.label}" is not installed.`);
1499 }1645 }
15001646
1647 const canonicalTheme = canonicalizeThemeDefinition(theme);
1501 const nextThemes = [...themes];1648 const nextThemes = [...themes];
1502 nextThemes[themeIndex] = theme;1649 nextThemes[themeIndex] = canonicalTheme;
1503 saveCustomThemes(nextThemes);1650
1504 return theme;1651 const nextStoredThemes: unknown[] = [];
1652 let replaced = false;
1653 for (const storedTheme of library.storedThemes) {
1654 if (!storedThemeHasId(storedTheme, theme.id)) {
1655 nextStoredThemes.push(storedTheme);
1656 } else if (!replaced) {
1657 nextStoredThemes.push(canonicalTheme);
1658 replaced = true;
1659 }
1660 }
1661 saveCustomThemes(nextStoredThemes, nextThemes);
1662 return canonicalTheme;
1505}1663}
15061664
1507export function removeCustomTheme(themeId: string): void {1665export function removeCustomTheme(themeId: string): void {
1508 const nextThemes = getCustomThemes().filter((theme) => theme.id !== themeId);1666 const library = getWritableCustomThemeLibrary();
1509 if (nextThemes.length === getCustomThemes().length) return;1667 const nextThemes = library.themes.filter((theme) => theme.id !== themeId);
1510 saveCustomThemes(nextThemes);1668 if (nextThemes.length === library.themes.length) return;
1669 saveCustomThemes(
1670 library.storedThemes.filter((storedTheme) => !storedThemeHasId(storedTheme, themeId)),
1671 nextThemes,
1672 );
1511}1673}
15121674
1513function parseThemeColorOverrides(value: unknown): ThemeColorOverrides {1675function parseThemeColorOverrides(value: unknown): ThemeColorOverrides {
...@@ -1518,10 +1680,13 @@ function parseThemeColorOverrides(value: unknown): ThemeColorOverrides {...@@ -1518,10 +1680,13 @@ function parseThemeColorOverrides(value: unknown): ThemeColorOverrides {
1518 if (!THEME_COLOR_ROLE_SET.has(role)) {1680 if (!THEME_COLOR_ROLE_SET.has(role)) {
1519 throw new Error(`"${role}" is not a supported theme color role.`);1681 throw new Error(`"${role}" is not a supported theme color role.`);
1520 }1682 }
1521 if (!isThemeColor(color)) {1683 const normalized = toCanonicalThemeColor(color);
1522 throw new Error(`The color for "${role}" must be a hex color such as #8b5cf6.`);1684 if (!normalized) {
1685 throw new Error(
1686 `The color for "${role}" must be a literal CSS color such as oklch(0.62 0.2 280).`,
1687 );
1523 }1688 }
1524 overrides[role as ThemeColorRole] = color;1689 overrides[role as ThemeColorRole] = normalized;
1525 }1690 }
1526 if (Object.keys(overrides).length === 0) {1691 if (Object.keys(overrides).length === 0) {
1527 throw new Error("Add at least one color role to the theme file.");1692 throw new Error("Add at least one color role to the theme file.");
...@@ -1586,14 +1751,15 @@ export function parseThemeFile(value: unknown): ThemeDefinition {...@@ -1586,14 +1751,15 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
1586}1751}
15871752
1588export function serializeThemeFile(theme: ThemeDefinition): string {1753export function serializeThemeFile(theme: ThemeDefinition): string {
1754 const canonicalTheme = canonicalizeThemeDefinition(theme);
1589 const file: ThemeFile = {1755 const file: ThemeFile = {
1590 version: THEME_FILE_VERSION,1756 version: THEME_FILE_VERSION,
1591 id: theme.id,1757 id: canonicalTheme.id,
1592 name: theme.label,1758 name: canonicalTheme.label,
1593 appearance: theme.appearance,1759 appearance: canonicalTheme.appearance,
1594 colors: theme.colors,1760 colors: canonicalTheme.colors,
1595 ...(theme.variants ? { variants: theme.variants } : {}),1761 ...(canonicalTheme.variants ? { variants: canonicalTheme.variants } : {}),
1596 ...(theme.managed ? { managed: true } : {}),1762 ...(canonicalTheme.managed ? { managed: true } : {}),
1597 };1763 };
1598 return `${JSON.stringify(file, null, 2)}\n`;1764 return `${JSON.stringify(file, null, 2)}\n`;
1599}1765}
apps/web/src/vscodeThemeImport.test.ts+26-19
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { getThemeColorsForMode, THEME_FILE_VERSION } from "./themePalette";3import { getThemeColorsForMode, themeColorToHex, THEME_FILE_VERSION } from "./themePalette";
4import {4import {
5 isVsCodeThemeFile,5 isVsCodeThemeFile,
6 pairVsCodeThemes,6 pairVsCodeThemes,
...@@ -8,9 +8,15 @@ import {...@@ -8,9 +8,15 @@ import {
8 resolveThemeLabelCollisions,8 resolveThemeLabelCollisions,
9} from "./vscodeThemeImport";9} from "./vscodeThemeImport";
1010
11function asHex(value: string): string {
12 const hex = themeColorToHex(value);
13 if (!hex) throw new Error(`Expected a theme color, received ${value}`);
14 return hex;
15}
16
11function contrastRatio(first: string, second: string): number {17function contrastRatio(first: string, second: string): number {
12 const toChannels = (value: string) => {18 const toChannels = (value: string) => {
13 const hex = value.slice(1);19 const hex = asHex(value).slice(1);
14 return [0, 1, 2].map(20 return [0, 1, 2].map(
15 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,21 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
16 );22 );
...@@ -69,18 +75,18 @@ describe("VS Code theme import", () => {...@@ -69,18 +75,18 @@ describe("VS Code theme import", () => {
69 // The slug name is read as words; a displayName would win verbatim.75 // The slug name is read as words; a displayName would win verbatim.
70 expect(theme.label).toBe("Pierre Dark Soft");76 expect(theme.label).toBe("Pierre Dark Soft");
71 expect(theme.appearance).toBe("dark");77 expect(theme.appearance).toBe("dark");
72 expect(theme.colors.canvas).toBe("#171717");78 expect(asHex(theme.colors.canvas)).toBe("#171717");
73 expect(theme.colors.text).toBe("#d4d4d4");79 expect(asHex(theme.colors.text)).toBe("#d4d4d4");
74 expect(theme.colors.accent).toBe("#69b1ff");80 expect(asHex(theme.colors.accent)).toBe("#69b1ff");
75 expect(theme.colors.sidebar).toBe("#101010");81 expect(asHex(theme.colors.sidebar)).toBe("#101010");
76 expect(theme.colors.terminalBackground).toBe("#101010");82 expect(asHex(theme.colors.terminalBackground)).toBe("#101010");
77 });83 });
7884
79 it("flattens alpha overlays onto the surface they sit on", () => {85 it("flattens alpha overlays onto the surface they sit on", () => {
80 const theme = parseVsCodeThemeFile(VSCODE_DARK);86 const theme = parseVsCodeThemeFile(VSCODE_DARK);
81 // #1f3e5e59 over the #101010 sidebar, not left semi-transparent.87 // #1f3e5e59 over the #101010 sidebar, not left semi-transparent.
82 expect(theme.colors.sidebarRowHover).toMatch(/^#[0-9a-f]{6}$/);88 expect(theme.colors.sidebarRowHover).toMatch(/^oklch\(/);
83 expect(theme.colors.sidebarRowHover).not.toBe("#1f3e5e59");89 expect(asHex(theme.colors.sidebarRowHover)).not.toBe("#1f3e5e59");
84 expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar);90 expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar);
85 });91 });
8692
...@@ -88,7 +94,7 @@ describe("VS Code theme import", () => {...@@ -88,7 +94,7 @@ describe("VS Code theme import", () => {
88 const theme = parseVsCodeThemeFile(VSCODE_DARK);94 const theme = parseVsCodeThemeFile(VSCODE_DARK);
89 const colors = getThemeColorsForMode(theme, "dark")!;95 const colors = getThemeColorsForMode(theme, "dark")!;
90 for (const value of Object.values(colors)) {96 for (const value of Object.values(colors)) {
91 expect(value).toMatch(/^#[0-9a-f]{3,8}$/i);97 expect(value).toMatch(/^oklch\(/);
92 }98 }
93 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(4.5);99 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(4.5);
94 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);100 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);
...@@ -117,7 +123,7 @@ describe("VS Code theme import", () => {...@@ -117,7 +123,7 @@ describe("VS Code theme import", () => {
117 type: "dark",123 type: "dark",
118 colors: { "editor.background": "#101010", "editor.foreground": "#111111" },124 colors: { "editor.background": "#101010", "editor.foreground": "#111111" },
119 });125 });
120 expect(theme.colors.text).not.toBe("#111111");126 expect(asHex(theme.colors.text)).not.toBe("#111111");
121 expect(contrastRatio(theme.colors.text, theme.colors.canvas)).toBeGreaterThanOrEqual(4.5);127 expect(contrastRatio(theme.colors.text, theme.colors.canvas)).toBeGreaterThanOrEqual(4.5);
122 });128 });
123129
...@@ -134,7 +140,7 @@ describe("VS Code theme import", () => {...@@ -134,7 +140,7 @@ describe("VS Code theme import", () => {
134 "terminal.background": "#fbfbfb",140 "terminal.background": "#fbfbfb",
135 },141 },
136 });142 });
137 expect(theme.colors.sidebar).toBe("#fafafa");143 expect(asHex(theme.colors.sidebar)).toBe("#fafafa");
138 expect(144 expect(
139 contrastRatio(theme.colors.sidebarForeground, theme.colors.sidebar),145 contrastRatio(theme.colors.sidebarForeground, theme.colors.sidebar),
140 ).toBeGreaterThanOrEqual(4.5);146 ).toBeGreaterThanOrEqual(4.5);
...@@ -156,10 +162,10 @@ describe("VS Code theme import", () => {...@@ -156,10 +162,10 @@ describe("VS Code theme import", () => {
156 "editor.selectionBackground": "color(display-p3 0.308664 0.645271 1.000000 / 0.300000)",162 "editor.selectionBackground": "color(display-p3 0.308664 0.645271 1.000000 / 0.300000)",
157 },163 },
158 });164 });
159 expect(theme.colors.canvas).toMatch(/^#0[89ab]/);165 expect(asHex(theme.colors.canvas)).toMatch(/^#0[89ab]/);
160 expect(theme.colors.text).toMatch(/^#f[a-f0-9]/);166 expect(asHex(theme.colors.text)).toMatch(/^#f[a-f0-9]/);
161 // The P3 blue lands in sRGB blue, not black or a clipped grey.167 // The P3 blue lands in sRGB blue, not black or a clipped grey.
162 const accent = theme.colors.accent;168 const accent = asHex(theme.colors.accent);
163 const [red, green, blue] = [1, 3, 5].map((index) =>169 const [red, green, blue] = [1, 3, 5].map((index) =>
164 Number.parseInt(accent.slice(index, index + 2), 16),170 Number.parseInt(accent.slice(index, index + 2), 16),
165 ) as [number, number, number];171 ) as [number, number, number];
...@@ -192,8 +198,8 @@ describe("VS Code theme import", () => {...@@ -192,8 +198,8 @@ describe("VS Code theme import", () => {
192 const github = themes[0]!;198 const github = themes[0]!;
193 expect(github.appearance).toBe("light");199 expect(github.appearance).toBe("light");
194 expect(getThemeColorsForMode(github, "dark")).not.toBeNull();200 expect(getThemeColorsForMode(github, "dark")).not.toBeNull();
195 expect(getThemeColorsForMode(github, "dark")!.canvas).toBe("#101014");201 expect(asHex(getThemeColorsForMode(github, "dark")!.canvas)).toBe("#101014");
196 expect(github.colors.canvas).toBe("#fdfdfd");202 expect(asHex(github.colors.canvas)).toBe("#fdfdfd");
197 // The unpaired dimmed variant stays a single dark theme.203 // The unpaired dimmed variant stays a single dark theme.
198 expect(getThemeColorsForMode(themes[2]!, "light")).toBeNull();204 expect(getThemeColorsForMode(themes[2]!, "light")).toBeNull();
199 });205 });
...@@ -220,14 +226,15 @@ describe("VS Code theme import", () => {...@@ -220,14 +226,15 @@ describe("VS Code theme import", () => {
220 // surface, plain surfaces) must stay near the canvas, not turn blue.226 // surface, plain surfaces) must stay near the canvas, not turn blue.
221 const theme = parseVsCodeThemeFile(VSCODE_DARK);227 const theme = parseVsCodeThemeFile(VSCODE_DARK);
222 const spread = (value: string) => {228 const spread = (value: string) => {
223 const channels = [1, 3, 5].map((index) => Number.parseInt(value.slice(index, index + 2), 16));229 const hex = asHex(value);
230 const channels = [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
224 return Math.max(...channels) - Math.min(...channels);231 return Math.max(...channels) - Math.min(...channels);
225 };232 };
226 expect(spread(theme.colors.codeBackground)).toBeLessThanOrEqual(8);233 expect(spread(theme.colors.codeBackground)).toBeLessThanOrEqual(8);
227 expect(spread(theme.colors.surface)).toBeLessThanOrEqual(8);234 expect(spread(theme.colors.surface)).toBeLessThanOrEqual(8);
228 expect(spread(theme.colors.text)).toBeLessThanOrEqual(12);235 expect(spread(theme.colors.text)).toBeLessThanOrEqual(12);
229 // The accent itself keeps the file's color.236 // The accent itself keeps the file's color.
230 expect(theme.colors.accent).toBe("#69b1ff");237 expect(asHex(theme.colors.accent)).toBe("#69b1ff");
231 });238 });
232239
233 it("tells same-named variants apart by their file names", () => {240 it("tells same-named variants apart by their file names", () => {
apps/web/src/vscodeThemeImport.ts+2-1
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 createVividThemeColors,2 createVividThemeColors,
3 getThemeModes,3 getThemeModes,
4 parseThemeFile,4 parseThemeFile,
5 themeColorToHex,
5 THEME_FILE_VERSION,6 THEME_FILE_VERSION,
6 type ThemeAppearance,7 type ThemeAppearance,
7 type ThemeColorRole,8 type ThemeColorRole,
...@@ -138,7 +139,7 @@ function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number {...@@ -138,7 +139,7 @@ function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number {
138}139}
139140
140function hexToRgb(value: string): VsCodeRgb {141function hexToRgb(value: string): VsCodeRgb {
141 return parseVsCodeColor(value) ?? { r: 0, g: 0, b: 0, a: 1 };142 return parseVsCodeColor(themeColorToHex(value) ?? value) ?? { r: 0, g: 0, b: 0, a: 1 };
142}143}
143144
144/**145/**
pnpm-lock.yaml+11
...@@ -570,6 +570,9 @@ importers:...@@ -570,6 +570,9 @@ importers:
570 class-variance-authority:570 class-variance-authority:
571 specifier: ^0.7.1571 specifier: ^0.7.1
572 version: 0.7.1572 version: 0.7.1
573 culori:
574 specifier: ^4.0.2
575 version: 4.0.2
573 effect:576 effect:
574 specifier: 4.0.0-beta.103577 specifier: 4.0.0-beta.103
575 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)578 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
...@@ -631,6 +634,9 @@ importers:...@@ -631,6 +634,9 @@ importers:
631 '@types/compression':634 '@types/compression':
632 specifier: ^1.8.1635 specifier: ^1.8.1
633 version: 1.8.1636 version: 1.8.1
637 '@types/culori':
638 specifier: ^4.0.1
639 version: 4.0.1
634 '@types/react':640 '@types/react':
635 specifier: ~19.2.14641 specifier: ~19.2.14
636 version: 19.2.16642 version: 19.2.16
...@@ -4794,6 +4800,9 @@ packages:...@@ -4794,6 +4800,9 @@ packages:
4794 '@types/connect@3.4.38':4800 '@types/connect@3.4.38':
4795 resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}4801 resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
47964802
4803 '@types/culori@4.0.1':
4804 resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==}
4805
4797 '@types/debug@4.1.13':4806 '@types/debug@4.1.13':
4798 resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}4807 resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
47994808
...@@ -14972,6 +14981,8 @@ snapshots:...@@ -14972,6 +14981,8 @@ snapshots:
14972 dependencies:14981 dependencies:
14973 '@types/node': 24.12.414982 '@types/node': 24.12.4
1497414983
14984 '@types/culori@4.0.1': {}
14985
14975 '@types/debug@4.1.13':14986 '@types/debug@4.1.13':
14976 dependencies:14987 dependencies:
14977 '@types/ms': 2.1.014988 '@types/ms': 2.1.0