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 @@
3535 // app colors before React mounts.
3636 const DEFAULT_THEME_PALETTES = {
3737 light: {
38 background: "#fdf7fd",
39 foreground: "#501854",
40 accent: "#db2777",
41 chrome: "#fdf7fd",
38 background: "oklch(0.982446 0.010114 325.653)",
39 foreground: "oklch(0.325698 0.116116 325.037)",
40 accent: "oklch(0.591646 0.217985 0.584)",
41 chrome: "oklch(0.982446 0.010114 325.653)",
4242 },
4343 dark: {
44 background: "#1f1a24",
45 foreground: "#f9f8fb",
46 accent: "#a3004c",
47 chrome: "#1f1a24",
44 background: "oklch(0.22813 0.020366 307.469)",
45 foreground: "oklch(0.980735 0.004092 301.426)",
46 accent: "oklch(0.460685 0.185347 4.099)",
47 chrome: "oklch(0.22813 0.020366 307.469)",
4848 },
4949 };
5050 // Keep this small boot-time copy in sync with the built-in palettes so
......@@ -52,72 +52,72 @@
5252 const BUILT_IN_THEME_PALETTES = {
5353 "t3-chat": {
5454 light: {
55 background: "#fdf7fd",
56 foreground: "#501854",
57 accent: "#db2777",
58 chrome: "#fdf7fd",
55 background: "oklch(0.982446 0.010114 325.653)",
56 foreground: "oklch(0.325698 0.116116 325.037)",
57 accent: "oklch(0.591646 0.217985 0.584)",
58 chrome: "oklch(0.982446 0.010114 325.653)",
5959 },
6060 dark: {
61 background: "#1f1a24",
62 foreground: "#f9f8fb",
63 accent: "#a3004c",
64 chrome: "#1f1a24",
61 background: "oklch(0.22813 0.020366 307.469)",
62 foreground: "oklch(0.980735 0.004092 301.426)",
63 accent: "oklch(0.460685 0.185347 4.099)",
64 chrome: "oklch(0.22813 0.020366 307.469)",
6565 },
6666 },
6767 grove: {
6868 light: {
69 background: "#f3f7f4",
70 foreground: "#241523",
71 accent: "#1b7d50",
72 chrome: "#f3f7f4",
69 background: "oklch(0.972369 0.005497 157.15)",
70 foreground: "oklch(0.222003 0.03479 328.979)",
71 accent: "oklch(0.523295 0.112292 158.089)",
72 chrome: "oklch(0.972369 0.005497 157.15)",
7373 },
7474 dark: {
75 background: "#1b2821",
76 foreground: "#fffaff",
77 accent: "#69d69a",
78 chrome: "#1b2821",
75 background: "oklch(0.260865 0.02152 162.75)",
76 foreground: "oklch(0.990339 0.008411 325.64)",
77 accent: "oklch(0.796228 0.133058 157.319)",
78 chrome: "oklch(0.260865 0.02152 162.75)",
7979 },
8080 },
8181 ocean: {
8282 light: {
83 background: "#f5f7f8",
84 foreground: "#241523",
85 accent: "#2672af",
86 chrome: "#f5f7f8",
83 background: "oklch(0.974199 0.002856 241.597)",
84 foreground: "oklch(0.222003 0.03479 328.979)",
85 accent: "oklch(0.536684 0.120219 247.01)",
86 chrome: "oklch(0.974199 0.002856 241.597)",
8787 },
8888 dark: {
89 background: "#17212b",
90 foreground: "#fffaff",
91 accent: "#70b9ee",
92 chrome: "#17212b",
89 background: "oklch(0.242641 0.024125 250.573)",
90 foreground: "oklch(0.990339 0.008411 325.64)",
91 accent: "oklch(0.758933 0.105833 241.548)",
92 chrome: "oklch(0.242641 0.024125 250.573)",
9393 },
9494 },
9595 ember: {
9696 light: {
97 background: "#f9f7f5",
98 foreground: "#241523",
99 accent: "#ae552a",
100 chrome: "#f9f7f5",
97 background: "oklch(0.976527 0.002685 60.725)",
98 foreground: "oklch(0.222003 0.03479 328.979)",
99 accent: "oklch(0.552831 0.129438 44.656)",
100 chrome: "oklch(0.976527 0.002685 60.725)",
101101 },
102102 dark: {
103 background: "#291e1a",
104 foreground: "#fffaff",
105 accent: "#f09a64",
106 chrome: "#291e1a",
103 background: "oklch(0.245899 0.019144 42.044)",
104 foreground: "oklch(0.990339 0.008411 325.64)",
105 accent: "oklch(0.762174 0.124117 52.082)",
106 chrome: "oklch(0.245899 0.019144 42.044)",
107107 },
108108 },
109109 iris: {
110110 light: {
111 background: "#f8f7f9",
112 foreground: "#241523",
113 accent: "#7253b9",
114 chrome: "#f8f7f9",
111 background: "oklch(0.976531 0.003855 303.226)",
112 foreground: "oklch(0.222003 0.03479 328.979)",
113 accent: "oklch(0.525348 0.15373 294.176)",
114 chrome: "oklch(0.976531 0.003855 303.226)",
115115 },
116116 dark: {
117 background: "#1d1929",
118 foreground: "#fffaff",
119 accent: "#9d7df2",
120 chrome: "#1d1929",
117 background: "oklch(0.225975 0.031062 293.741)",
118 foreground: "oklch(0.990339 0.008411 325.64)",
119 accent: "oklch(0.671712 0.169136 293.929)",
120 chrome: "oklch(0.225975 0.031062 293.741)",
121121 },
122122 },
123123 };
......@@ -152,9 +152,26 @@
152152 }
153153 };
154154
155 const isHexColor = (value) =>
156 typeof value === "string" &&
157 /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value);
155 // The runtime decodes the same literal CSS formats into OKLCH. Keep
156 // contextual colors out: they cannot be resolved from stored data.
157 const SYSTEM_COLOR_KEYWORD =
158 /^(?:accentcolor(?:text)?|active(?:border|caption|text)|appworkspace|background|button(?:border|face|highlight|shadow|text)|canvas(?:text)?|captiontext|field(?:text)?|graytext|highlight(?:text)?|inactive(?:border|caption(?:text)?)|info(?:background|text)|linktext|mark(?:text)?|menu(?:text)?|scrollbar|selecteditem(?:text)?|threed(?:darkshadow|face|highlight|lightshadow|shadow)|visitedtext|window(?:frame|text)?)$/i;
159 const isThemeColor = (value) => {
160 if (typeof value !== "string" || typeof CSS === "undefined") return false;
161 const color = value.trim();
162 if (
163 /^(?:currentcolor|inherit|initial|revert|revert-layer|unset)$/i.test(color) ||
164 SYSTEM_COLOR_KEYWORD.test(color) ||
165 /\bfrom\b|(?:var|env|attr|calc|min|max|clamp)\(/i.test(color)
166 ) {
167 return false;
168 }
169 const isLiteralColor =
170 /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color) ||
171 /^[a-z]+$/i.test(color) ||
172 /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i.test(color);
173 return isLiteralColor && CSS.supports("color", color);
174 };
158175 const isRecord = (value) =>
159176 typeof value === "object" && value !== null && !Array.isArray(value);
160177 const isThemeId = (value) =>
......@@ -322,13 +339,13 @@
322339 const fallbackSplash = isDark ? SPLASH_COLORS.dark : SPLASH_COLORS.light;
323340 const customSplash = customColors
324341 ? {
325 background: isHexColor(customColors.canvas)
342 background: isThemeColor(customColors.canvas)
326343 ? customColors.canvas
327344 : (customDefaults?.background ?? fallbackSplash.background),
328 foreground: isHexColor(customColors.text)
345 foreground: isThemeColor(customColors.text)
329346 ? customColors.text
330347 : (customDefaults?.foreground ?? fallbackSplash.foreground),
331 accent: isHexColor(customColors.accent)
348 accent: isThemeColor(customColors.accent)
332349 ? customColors.accent
333350 : (customDefaults?.accent ?? fallbackSplash.accent),
334351 }
......@@ -350,7 +367,7 @@
350367 }
351368 document.documentElement.classList.toggle("dark", isDark);
352369 const chromeColor = customColors
353 ? isHexColor(customColors.chrome)
370 ? isThemeColor(customColors.chrome)
354371 ? customColors.chrome
355372 : (customDefaults?.chrome ?? fallbackSplash.background)
356373 : builtInSplash
apps/web/package.json+2
......@@ -31,6 +31,7 @@
3131 "@tanstack/react-pacer": "^0.19.4",
3232 "@tanstack/react-router": "^1.160.2",
3333 "class-variance-authority": "^0.7.1",
34 "culori": "^4.0.2",
3435 "effect": "catalog:",
3536 "jose": "catalog:",
3637 "lexical": "^0.41.0",
......@@ -53,6 +54,7 @@
5354 "@tanstack/router-plugin": "^1.161.0",
5455 "@types/babel__core": "^7.20.5",
5556 "@types/compression": "^1.8.1",
57 "@types/culori": "^4.0.1",
5658 "@types/react": "~19.2.14",
5759 "@types/react-dom": "~19.2.3",
5860 "@vercel/config": "^0.3.0",
apps/web/src/components/settings/ThemeColorPicker.tsx+11-29
......@@ -1,6 +1,6 @@
11import type { KeyboardEvent, PointerEvent } from "react";
22import { memo, useCallback, useEffect, useRef, useState } from "react";
3import { isThemeColor, type ThemeColorRole } from "../../themePalette";
3import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette";
44import { cn } from "../../lib/utils";
55import { Input } from "../ui/input";
66import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
......@@ -37,39 +37,18 @@ function clampThemeColor(value: number, min = 0, max = 1) {
3737}
3838
3939/**
40 * The picker's plane and sliders operate on opaque six-digit hex, but theme
41 * colors may carry alpha. The suffix is preserved separately and re-attached
42 * on commit so adjusting hue or brightness cannot change transparency.
40 * The picker remains an sRGB/hex adapter over the OKLCH palette engine. Alpha
41 * is preserved separately and re-attached on commit so adjusting hue or
42 * brightness cannot change transparency.
4343 */
4444function themePickerAlphaSuffix(value: string): string {
45 const trimmed = value.trim().toLowerCase();
46 const alpha = /^#[0-9a-f]{4}$/.test(trimmed)
47 ? trimmed.slice(4).repeat(2)
48 : /^#[0-9a-f]{8}$/.test(trimmed)
49 ? trimmed.slice(7)
50 : "";
45 const normalized = themeColorToHex(value) ?? "";
46 const alpha = normalized.length === 9 ? normalized.slice(7) : "";
5147 return alpha === "ff" ? "" : alpha;
5248}
5349
5450function normalizeThemePickerColor(value: string): string {
55 const trimmed = value.trim();
56 if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
57 return `#${trimmed
58 .slice(1)
59 .split("")
60 .map((character) => `${character}${character}`)
61 .join("")}`;
62 }
63 if (/^#[0-9a-f]{4}$/i.test(trimmed)) {
64 return `#${trimmed
65 .slice(1, 4)
66 .split("")
67 .map((character) => `${character}${character}`)
68 .join("")}`;
69 }
70 if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed;
71 if (/^#[0-9a-f]{8}$/i.test(trimmed)) return trimmed.slice(0, 7);
72 return "#000000";
51 return (themeColorToHex(value) ?? "#000000").slice(0, 7);
7352}
7453
7554function themeHexToHsv(hex: string): ThemeColorHsv {
......@@ -505,6 +484,9 @@ export const ThemeColorField = memo(function ThemeColorField({
505484 const label = customLabel ?? getThemeRoleLabel(role);
506485 const isColorValue = isThemeColor(value);
507486 const swatchValue = isColorValue ? value : "#000000";
487 const editorValue = value.trim().toLowerCase().startsWith("oklch(")
488 ? (themeColorToHex(value) ?? value)
489 : value;
508490
509491 return (
510492 <div
......@@ -542,7 +524,7 @@ export const ThemeColorField = memo(function ThemeColorField({
542524 onPointerDown={() => onSelect?.(role)}
543525 size="sm"
544526 unstyled
545 value={value}
527 value={editorValue}
546528 />
547529 </div>
548530 </div>
apps/web/src/index.css+124-112
......@@ -268,16 +268,28 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
268268 :root {
269269 /* Dev artwork defaults. Built-in themes override these seven pigments in
270270 the components layer; Nightly derives a darker matching palette. */
271 --stage-art-top: #67c2ff;
272 --stage-art-mid: #347ff8;
273 --stage-art-bottom: #1538d0;
274 --stage-art-highlight: #d4f6ff;
275 --stage-art-secondary: #65c8ff;
276 --stage-art-tertiary: #7c8bff;
277 --stage-art-line: #ddf7ff;
278 --stage-night-top: color-mix(in oklch, var(--stage-art-top) 38%, #32155b);
279 --stage-night-mid: color-mix(in oklch, var(--stage-art-mid) 38%, #151443);
280 --stage-night-bottom: color-mix(in oklch, var(--stage-art-bottom) 38%, #07152f);
271 --stage-art-top: oklch(0.782169 0.123386 240.226);
272 --stage-art-mid: oklch(0.616111 0.195824 259.735);
273 --stage-art-bottom: oklch(0.441553 0.232394 265.474);
274 --stage-art-highlight: oklch(0.951597 0.037289 215.482);
275 --stage-art-secondary: oklch(0.794668 0.12136 235.46);
276 --stage-art-tertiary: oklch(0.678991 0.170261 275.365);
277 --stage-art-line: oklch(0.959666 0.029238 218.179);
278 --stage-night-top: color-mix(
279 in oklch,
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 );
281293 --stage-night-highlight: var(--stage-art-highlight);
282294 --stage-night-secondary: color-mix(
283295 in oklch,
......@@ -289,13 +301,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
289301 var(--stage-art-tertiary) 60%,
290302 var(--stage-night-top)
291303 );
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));
293305 }
294306
295307 .dark {
296 --stage-art-top: #3a7ad1;
297 --stage-art-mid: #2050ae;
298 --stage-art-bottom: #101f6e;
308 --stage-art-top: oklch(0.581473 0.149124 256.9);
309 --stage-art-mid: oklch(0.456509 0.159377 261.945);
310 --stage-art-bottom: oklch(0.291327 0.136578 267.649);
299311 }
300312
301313 * {
......@@ -373,131 +385,131 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
373385 These colors stay deliberately deep at the top edge so the white stage
374386 wordmark and controls retain contrast in both appearance modes. */
375387 html[data-theme-id="t3-chat"] {
376 --stage-art-top: #f28ac1;
377 --stage-art-mid: #cb3e86;
378 --stage-art-bottom: #73194f;
379 --stage-art-highlight: #ffe6f6;
380 --stage-art-secondary: #ff82bc;
381 --stage-art-tertiary: #c67bf1;
382 --stage-art-line: #ffe7f8;
388 --stage-art-top: oklch(0.760675 0.141361 348.019);
389 --stage-art-mid: oklch(0.588093 0.188832 353.138);
390 --stage-art-bottom: oklch(0.383959 0.135096 348.656);
391 --stage-art-highlight: oklch(0.949689 0.034241 339.171);
392 --stage-art-secondary: oklch(0.763402 0.163836 352.525);
393 --stage-art-tertiary: oklch(0.70819 0.180285 311.949);
394 --stage-art-line: oklch(0.952158 0.034194 336.179);
383395 }
384396
385397 html.dark[data-theme-id="t3-chat"] {
386 --stage-art-top: #a8467e;
387 --stage-art-mid: #742253;
388 --stage-art-bottom: #390e2d;
389 --stage-art-highlight: #ffd8ee;
390 --stage-art-secondary: #de639e;
391 --stage-art-tertiary: #9c64cf;
392 --stage-art-line: #ffe4f4;
398 --stage-art-top: oklch(0.540689 0.143665 347.587);
399 --stage-art-mid: oklch(0.396586 0.126592 347.6);
400 --stage-art-bottom: oklch(0.249959 0.079694 340.523);
401 --stage-art-highlight: oklch(0.921297 0.051708 343.229);
402 --stage-art-secondary: oklch(0.667398 0.165674 352.549);
403 --stage-art-tertiary: oklch(0.609315 0.163722 306.315);
404 --stage-art-line: oklch(0.945349 0.036045 341.433);
393405 }
394406
395407 html[data-theme-id="grove"] {
396 --stage-art-top: #6cc492;
397 --stage-art-mid: #2d8a5e;
398 --stage-art-bottom: #174a33;
399 --stage-art-highlight: #e4fff0;
400 --stage-art-secondary: #79d8a5;
401 --stage-art-tertiary: #d5b563;
402 --stage-art-line: #eafff2;
403 --stage-night-top: #286548;
404 --stage-night-mid: #174a35;
405 --stage-night-bottom: #0a2c21;
406 --stage-night-highlight: #c4eed5;
407 --stage-night-secondary: #54a878;
408 --stage-night-tertiary: #b79b4d;
409 --stage-night-line: #d7f6e2;
408 --stage-art-top: oklch(0.751465 0.112731 157.313);
409 --stage-art-mid: oklch(0.567105 0.11099 158.859);
410 --stage-art-bottom: oklch(0.367918 0.067635 160.558);
411 --stage-art-highlight: oklch(0.976387 0.034353 161.456);
412 --stage-art-secondary: oklch(0.80968 0.117071 158.817);
413 --stage-art-tertiary: oklch(0.784102 0.108047 88.516);
414 --stage-art-line: oklch(0.981098 0.027812 158.718);
415 --stage-night-top: oklch(0.45805 0.079616 160.092);
416 --stage-night-mid: oklch(0.368483 0.065631 162.719);
417 --stage-night-bottom: oklch(0.264797 0.044397 168.088);
418 --stage-night-highlight: oklch(0.912563 0.055173 158.974);
419 --stage-night-secondary: oklch(0.665652 0.109731 156.599);
420 --stage-night-tertiary: oklch(0.698651 0.103024 89.828);
421 --stage-night-line: oklch(0.945336 0.041923 157.222);
410422 }
411423
412424 html.dark[data-theme-id="grove"] {
413 --stage-art-top: #438e65;
414 --stage-art-mid: #286447;
415 --stage-art-bottom: #123525;
416 --stage-art-highlight: #d2fbe2;
417 --stage-art-secondary: #62bf88;
418 --stage-art-tertiary: #c99b45;
419 --stage-art-line: #e0fae9;
420 --stage-night-top: #24523a;
421 --stage-night-mid: #113323;
422 --stage-night-bottom: #071d16;
423 --stage-night-highlight: #b5dfc4;
424 --stage-night-secondary: #478d66;
425 --stage-night-tertiary: #a9853b;
426 --stage-night-line: #d0ead9;
425 --stage-art-top: oklch(0.58719 0.09869 157.426);
426 --stage-art-mid: oklch(0.454979 0.079031 159.756);
427 --stage-art-bottom: oklch(0.297856 0.050355 161.167);
428 --stage-art-highlight: oklch(0.952407 0.053872 158.44);
429 --stage-art-secondary: oklch(0.732591 0.120606 155.853);
430 --stage-art-tertiary: oklch(0.716282 0.116547 80.563);
431 --stage-art-line: oklch(0.961577 0.035285 157.03);
432 --stage-night-top: oklch(0.398632 0.065534 158.601);
433 --stage-night-mid: oklch(0.290561 0.049694 160.456);
434 --stage-night-bottom: oklch(0.210147 0.03173 169.818);
435 --stage-night-highlight: oklch(0.866303 0.057526 156.796);
436 --stage-night-secondary: oklch(0.586553 0.093722 157.365);
437 --stage-night-tertiary: oklch(0.6364 0.101769 82.985);
438 --stage-night-line: oklch(0.913292 0.035718 156.976);
427439 }
428440
429441 html[data-theme-id="ocean"] {
430 --stage-art-top: #77c9e3;
431 --stage-art-mid: #3384bd;
432 --stage-art-bottom: #174d7a;
433 --stage-art-highlight: #e5faff;
434 --stage-art-secondary: #70c9df;
435 --stage-art-tertiary: #5cc7be;
436 --stage-art-line: #e9fbff;
442 --stage-art-top: oklch(0.792993 0.088072 220.421);
443 --stage-art-mid: oklch(0.590296 0.116841 243.184);
444 --stage-art-bottom: oklch(0.409123 0.093718 247.98);
445 --stage-art-highlight: oklch(0.971217 0.023008 213.615);
446 --stage-art-secondary: oklch(0.788391 0.090856 215.684);
447 --stage-art-tertiary: oklch(0.76441 0.099607 187.893);
448 --stage-art-line: oklch(0.976025 0.019647 212.543);
437449 }
438450
439451 html.dark[data-theme-id="ocean"] {
440 --stage-art-top: #4288ac;
441 --stage-art-mid: #285d84;
442 --stage-art-bottom: #132e49;
443 --stage-art-highlight: #d9f5ff;
444 --stage-art-secondary: #61b5d4;
445 --stage-art-tertiary: #51b8b3;
446 --stage-art-line: #dff7ff;
452 --stage-art-top: oklch(0.59663 0.089167 233.427);
453 --stage-art-mid: oklch(0.461094 0.084904 243.478);
454 --stage-art-bottom: oklch(0.294818 0.05947 250.526);
455 --stage-art-highlight: oklch(0.952907 0.032224 221.27);
456 --stage-art-secondary: oklch(0.732079 0.09296 224.414);
457 --stage-art-tertiary: oklch(0.720885 0.095495 190.903);
458 --stage-art-line: oklch(0.961039 0.027355 219.756);
447459 }
448460
449461 html[data-theme-id="ember"] {
450 --stage-art-top: #f4a26e;
451 --stage-art-mid: #d66036;
452 --stage-art-bottom: #7c291e;
453 --stage-art-highlight: #fff0df;
454 --stage-art-secondary: #ffad77;
455 --stage-art-tertiary: #ef6f69;
456 --stage-art-line: #fff3e7;
457 --stage-night-top: #8a442c;
458 --stage-night-mid: #5c271c;
459 --stage-night-bottom: #32100d;
460 --stage-night-highlight: #ffd6ba;
461 --stage-night-secondary: #cb7148;
462 --stage-night-tertiary: #ad4b45;
463 --stage-night-line: #ffe0c9;
462 --stage-art-top: oklch(0.782439 0.118806 52.569);
463 --stage-art-mid: oklch(0.62863 0.15922 39.401);
464 --stage-art-bottom: oklch(0.404251 0.117664 30.424);
465 --stage-art-highlight: oklch(0.962469 0.02774 70.95);
466 --stage-art-secondary: oklch(0.815735 0.11904 53.659);
467 --stage-art-tertiary: oklch(0.69361 0.159346 24.787);
468 --stage-art-line: oklch(0.970313 0.02052 67.583);
469 --stage-night-top: oklch(0.470236 0.102322 39.355);
470 --stage-night-mid: oklch(0.345367 0.080697 33.217);
471 --stage-night-bottom: oklch(0.227386 0.056077 27.437);
472 --stage-night-highlight: oklch(0.903891 0.059281 56.181);
473 --stage-night-secondary: oklch(0.641705 0.126508 44.376);
474 --stage-night-tertiary: oklch(0.538694 0.129931 25.865);
475 --stage-night-line: oklch(0.926348 0.046029 58.73);
464476 }
465477
466478 html.dark[data-theme-id="ember"] {
467 --stage-art-top: #ba6540;
468 --stage-art-mid: #803a29;
469 --stage-art-bottom: #3c1916;
470 --stage-art-highlight: #ffe1cd;
471 --stage-art-secondary: #e68156;
472 --stage-art-tertiary: #d85b58;
473 --stage-art-line: #ffe8d7;
474 --stage-night-top: #6a3425;
475 --stage-night-mid: #3e1b15;
476 --stage-night-bottom: #1d0d0b;
477 --stage-night-highlight: #efc4a9;
478 --stage-night-secondary: #a85e3f;
479 --stage-night-tertiary: #8f413e;
480 --stage-night-line: #f2d2bc;
479 --stage-art-top: oklch(0.597533 0.120694 43.455);
480 --stage-art-mid: oklch(0.437763 0.101287 34.86);
481 --stage-art-bottom: oklch(0.264269 0.055858 26.548);
482 --stage-art-highlight: oklch(0.929214 0.042638 55.801);
483 --stage-art-secondary: oklch(0.705592 0.137369 43.176);
484 --stage-art-tertiary: oklch(0.629583 0.158322 24.088);
485 --stage-art-line: oklch(0.945058 0.033906 58.824);
486 --stage-night-top: oklch(0.392352 0.081287 36.444);
487 --stage-night-mid: oklch(0.271305 0.056352 31.135);
488 --stage-night-bottom: oklch(0.182126 0.028154 27.774);
489 --stage-night-highlight: oklch(0.851007 0.061294 53.805);
490 --stage-night-secondary: oklch(0.560789 0.10645 42.953);
491 --stage-night-tertiary: oklch(0.476228 0.106656 24.165);
492 --stage-night-line: oklch(0.884931 0.046607 56.556);
481493 }
482494
483495 html[data-theme-id="iris"] {
484 --stage-art-top: #b49ae8;
485 --stage-art-mid: #7a5ac4;
486 --stage-art-bottom: #422d80;
487 --stage-art-highlight: #f2eaff;
488 --stage-art-secondary: #b79af1;
489 --stage-art-tertiary: #ec79c7;
490 --stage-art-line: #f6eeff;
496 --stage-art-top: oklch(0.738305 0.113454 298.926);
497 --stage-art-mid: oklch(0.551826 0.159189 294.531);
498 --stage-art-bottom: oklch(0.37147 0.133317 289.999);
499 --stage-art-highlight: oklch(0.949472 0.029204 303.081);
500 --stage-art-secondary: oklch(0.745085 0.125892 298.647);
501 --stage-art-tertiary: oklch(0.73066 0.167815 340.964);
502 --stage-art-line: oklch(0.960278 0.024064 306.969);
491503 }
492504
493505 html.dark[data-theme-id="iris"] {
494 --stage-art-top: #8063c4;
495 --stage-art-mid: #513990;
496 --stage-art-bottom: #261c53;
497 --stage-art-highlight: #e8ddff;
498 --stage-art-secondary: #9e83dc;
499 --stage-art-tertiary: #d96ab7;
500 --stage-art-line: #eee7ff;
506 --stage-art-top: oklch(0.57297 0.145973 295.185);
507 --stage-art-mid: oklch(0.419499 0.13752 292.131);
508 --stage-art-bottom: oklch(0.274235 0.095798 286.608);
509 --stage-art-highlight: oklch(0.916698 0.047206 300.224);
510 --stage-art-secondary: oklch(0.670994 0.13095 296.689);
511 --stage-art-tertiary: oklch(0.679357 0.165376 340.439);
512 --stage-art-line: oklch(0.940582 0.032921 299.076);
501513 }
502514
503515 .workspace-topbar {
apps/web/src/themeBoot.test.ts+66-3
......@@ -15,6 +15,7 @@ import {
1515 OCEAN_THEME,
1616 THEME_APPEARANCE_MODE_STORAGE_KEY,
1717 THEME_FOLLOW_SYSTEM_STORAGE_KEY,
18 toCanonicalThemeColor,
1819} from "./themePalette";
1920
2021const THEME_STORAGE_KEY = "t3code:theme";
......@@ -83,7 +84,12 @@ function runBootScript(options: {
8384 matchMedia: () => ({ matches: options.prefersDark }),
8485 };
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
8894 return {
8995 isDark: classes.has("dark"),
......@@ -275,6 +281,63 @@ describe("index.html boot script", () => {
275281 expect(aurora.metaContent).toBe(DEFAULT_DARK_CHROME);
276282 });
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
278341 // Asserting against the real palette definitions (not literals) turns the
279342 // boot script's hand-maintained copy into a CI-enforced contract: any
280343 // palette change breaks this test until the copy in index.html is updated.
......@@ -410,9 +473,9 @@ describe("index.html boot script", () => {
410473 });
411474
412475 expect(boot.themeId).toBe("partial");
413 expect(boot.bootVariables["--boot-background"]).toBe("#1f1a24");
476 expect(boot.bootVariables["--boot-background"]).toBe(getDefaultThemeColors("dark").canvas);
414477 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);
416479 expect(boot.backgroundColor).toBe(DEFAULT_DARK_CHROME);
417480 expect(boot.metaContent).toBe(DEFAULT_DARK_CHROME);
418481 });
apps/web/src/themePalette.test.ts+285-48
......@@ -15,6 +15,7 @@ import {
1515 canonicalThemePreference,
1616 parseThemeFile,
1717 parseThemeHalves,
18 removeCustomTheme,
1819 resolveDesktopTheme,
1920 resolveThemeAppearance,
2021 serializeThemeFile,
......@@ -31,12 +32,35 @@ import {
3132 createManagedThemeColors,
3233 createVividThemeColors,
3334 getDefaultThemeColors,
35 themeColorToHex,
36 toCanonicalThemeColor,
3437 THEME_FILE_VERSION,
3538} 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
3761function contrastRatio(first: string, second: string): number {
3862 const toRgb = (value: string) => {
39 const hex = value.slice(1);
63 const hex = asHex(value).slice(1);
4064 return [0, 1, 2].map(
4165 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
4266 );
......@@ -56,8 +80,8 @@ describe("theme files", () => {
5680 const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00");
5781 const darkDefaults = getDefaultThemeColors("dark");
5882
59 expect(light.canvas).not.toBe("#111827");
60 expect(dark.canvas).not.toBe("#ffffff");
83 expect(asHex(light.canvas)).not.toBe("#111827");
84 expect(asHex(dark.canvas)).not.toBe("#ffffff");
6185 expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5);
6286 expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5);
6387 expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5);
......@@ -73,7 +97,7 @@ describe("theme files", () => {
7397 // Status colors fall back to T3 Code's standard red and amber rather than
7498 // the flagship palette's, so no generated theme inherits a brand tint.
7599 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 [
77101 number,
78102 number,
79103 number,
......@@ -92,7 +116,7 @@ describe("theme files", () => {
92116 expect(warnRed).toBeGreaterThan(warnBlue);
93117 expect(warnGreen).toBeGreaterThan(warnBlue);
94118 }
95 expect(dark.error).not.toBe(darkDefaults.error);
119 expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error));
96120 });
97121
98122 it("derives readable, distinctive vivid palettes from exact seeds", () => {
......@@ -108,8 +132,10 @@ describe("theme files", () => {
108132 for (const [appearance, canvas, accent] of seeds) {
109133 const colors = createVividThemeColors(appearance, canvas, accent);
110134 // Exact seeds are honored.
111 expect(colors.canvas).toBe(canvas);
112 expect(colors.accent).toBe(accent);
135 expect(colors.canvas).toMatch(/^oklch\(/);
136 expect(colors.accent).toMatch(/^oklch\(/);
137 expect(asHex(colors.canvas)).toBe(canvas);
138 expect(asHex(colors.accent)).toBe(accent);
113139 // Readability is solved per surface.
114140 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7);
115141 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5);
......@@ -131,7 +157,7 @@ describe("theme files", () => {
131157 // The companion action is a distinct voice, not the accent again.
132158 expect(colors.messageAction).not.toBe(colors.accent);
133159 // Update family follows the theme, not the default palette.
134 expect(colors.update).toBe(accent);
160 expect(asHex(colors.update)).toBe(accent);
135161 }
136162 });
137163
......@@ -170,13 +196,53 @@ describe("theme files", () => {
170196 label: "Ocean dusk",
171197 appearance: "dark",
172198 colors: {
173 canvas: "#07152f",
174 accent: "#67c2ff",
175 placeholder: "#968d9f",
199 canvas: canonical("#07152f"),
200 accent: canonical("#67c2ff"),
201 placeholder: canonical("#968d9f"),
176202 },
177203 });
178204 });
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
180246 it("rejects unknown roles and invalid color values", () => {
181247 expect(() =>
182248 parseThemeFile({
......@@ -194,16 +260,20 @@ describe("theme files", () => {
194260 appearance: "light",
195261 colors: { accent: "var(--danger)" },
196262 }),
197 ).toThrow('The color for "accent" must be a hex color');
263 ).toThrow('The color for "accent" must be a literal CSS color');
198264 });
199265
200 it("serializes a theme back into the importable file shape", () => {
201 const serialized = serializeThemeFile(T3_CHAT_THEME);
266 it("canonicalizes the explicitly exported theme", () => {
267 const serialized = serializeThemeFile({
268 ...T3_CHAT_THEME,
269 colors: { ...T3_CHAT_THEME.colors, accent: "hsl(263 70% 58%)" },
270 });
202271 expect(JSON.parse(serialized)).toMatchObject({
203272 version: THEME_FILE_VERSION,
204273 id: T3_CHAT_THEME.id,
205274 name: T3_CHAT_THEME.label,
206275 appearance: "light",
276 colors: { accent: canonical("hsl(263 70% 58%)") },
207277 });
208278 });
209279
......@@ -257,8 +327,8 @@ describe("theme files", () => {
257327
258328 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
259329 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({
260 canvas: "#101827",
261 text: "#eef5ff",
330 canvas: canonical("#101827"),
331 text: canonical("#eef5ff"),
262332 });
263333 expect(getThemeModes(T3_CHAT_THEME)).toEqual(["light", "dark"]);
264334 expect(resolveThemeAppearance(T3_CHAT_THEME.id, true, true)).toBe("dark");
......@@ -266,13 +336,13 @@ describe("theme files", () => {
266336 expect(resolveThemeAppearance(T3_CHAT_THEME.id, false, false, "dark")).toBe("dark");
267337 expect(resolveDesktopTheme(T3_CHAT_THEME.id, false, "dark")).toBe("dark");
268338 expect(JSON.parse(serializeThemeFile(theme)).variants.dark).toMatchObject({
269 canvas: "#101827",
270 text: "#eef5ff",
339 canvas: canonical("#101827"),
340 text: canonical("#eef5ff"),
271341 });
272342 });
273343
274344 it("keeps the T3 Chat palette faithful and readable", () => {
275 expect(T3_CHAT_THEME.colors).toMatchObject({
345 expectThemeColors(T3_CHAT_THEME.colors, {
276346 canvas: "#fdf7fd",
277347 chrome: "#fdf7fd",
278348 toolbarBorder: "#efbdeb",
......@@ -287,7 +357,7 @@ describe("theme files", () => {
287357 accentSurface: "#f3e6f5",
288358 sidebar: "#f2e1f4",
289359 });
290 expect(T3_CHAT_THEME.variants?.dark).toMatchObject({
360 expectThemeColors(T3_CHAT_THEME.variants!.dark!, {
291361 canvas: "#1f1a24",
292362 chrome: "#1f1a24",
293363 surface: "#29232d",
......@@ -321,8 +391,8 @@ describe("theme files", () => {
321391 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
322392 expect(theme.sidebarArtwork).toBe(true);
323393 expect(themeAllowsSidebarArtwork(theme.id)).toBe(true);
324 expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i);
325 expect(theme.variants?.dark?.accent).toMatch(/^#[0-9a-f]{6}$/i);
394 expect(theme.colors.accent).toMatch(/^oklch\(/);
395 expect(theme.variants?.dark?.accent).toMatch(/^oklch\(/);
326396
327397 for (const mode of ["light", "dark"] as const) {
328398 const colors = getThemeColorsForMode(theme, mode);
......@@ -380,7 +450,9 @@ describe("theme files", () => {
380450 });
381451
382452 expect(getThemeModes(theme)).toEqual(["dark"]);
383 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ canvas: "#111827" });
453 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({
454 canvas: canonical("#111827"),
455 });
384456 expect(getThemeColorsForMode(theme, "light")).toBeNull();
385457 });
386458
......@@ -419,8 +491,16 @@ describe("theme files", () => {
419491 vi.unstubAllGlobals();
420492 });
421493
422 it("updates a personal theme without changing its id", () => {
494 it("canonicalizes explicit writes without migrating untouched themes", () => {
423495 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]));
424504 vi.stubGlobal("window", {
425505 localStorage: {
426506 getItem: (key: string) => stored.get(key) ?? null,
......@@ -442,23 +522,175 @@ describe("theme files", () => {
442522 const updatedTheme = updateCustomTheme({
443523 ...createdTheme,
444524 label: "Aurora Night",
445 colors: { ...createdTheme.colors, accent: "#7c3aed" },
525 colors: { ...createdTheme.colors, accent: "hsl(263 70% 58%)" },
446526 });
447527
448528 expect(updatedTheme).toMatchObject({
449529 id: "aurora",
450530 label: "Aurora Night",
531 colors: { accent: canonical("hsl(263 70% 58%)") },
451532 });
452533 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");
453542 invalidateCustomThemes();
454 expect(getCustomThemes()).toEqual([updatedTheme]);
455 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({
543 expect(getCustomThemes().find((theme) => theme.id === "aurora")).toMatchObject({
456544 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,
457680 label: "Aurora Night",
681 colors: { ...installedTheme.colors, accent: "hsl(263 70% 58%)" },
458682 });
459 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).not.toHaveProperty(
460 "sidebarArtwork",
683
684 const updatedLibrary = JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]");
685 expect(updatedLibrary.filter((entry: { id?: string }) => entry.id === "aurora")).toHaveLength(
686 1,
461687 );
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
463695 vi.unstubAllGlobals();
464696 invalidateCustomThemes();
......@@ -530,23 +762,23 @@ describe("stored theme preferences", () => {
530762 expect(isKnownThemePreference("missing-theme")).toBe(false);
531763 });
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();
534778 vi.stubGlobal("window", {
535779 localStorage: {
536 getItem: (key: string) =>
537 key === CUSTOM_THEMES_STORAGE_KEY
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,
780 getItem: (key: string) => (key === CUSTOM_THEMES_STORAGE_KEY ? storedThemes : null),
781 setItem,
550782 },
551783 });
552784 invalidateCustomThemes();
......@@ -555,11 +787,16 @@ describe("stored theme preferences", () => {
555787 expect(themes).toHaveLength(1);
556788 expect(themes[0]).toMatchObject({
557789 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",
559799 });
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
564801 vi.unstubAllGlobals();
565802 invalidateCustomThemes();
apps/web/src/themePalette.ts+379-213
......@@ -1,4 +1,6 @@
11import * as Schema from "effect/Schema";
2import "culori/css";
3import { converter, parse } from "culori/fn";
24
35export const T3_CHAT_THEME_ID = "t3-chat" as const;
46export const T3_CHAT_THEME_LABEL = "T3 Chat";
......@@ -129,7 +131,16 @@ const RESERVED_THEME_IDS = new Set([
129131]);
130132
131133const 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;
133144const themePreviewListeners = new Set<() => void>();
134145let themePreviewSidebarArtwork: boolean | null = null;
135146
......@@ -157,10 +168,7 @@ function isThemeAppearance(value: unknown): value is ThemeAppearance {
157168}
158169
159170export function isThemeColor(value: unknown): value is string {
160 return (
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 );
171 return typeof value === "string" && toCanonicalThemeColor(value) !== null;
164172}
165173
166174function isThemeId(value: unknown): value is string {
......@@ -180,8 +188,9 @@ function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): Th
180188 // Tolerate unknown roles and malformed values so themes saved by other
181189 // builds (for example one that adds a new role) keep their remaining colors.
182190 for (const [role, color] of Object.entries(value)) {
183 if (THEME_COLOR_ROLE_SET.has(role) && isThemeColor(color)) {
184 colors[role as ThemeColorRole] = color;
191 const normalized = toCanonicalThemeColor(color);
192 if (THEME_COLOR_ROLE_SET.has(role) && normalized) {
193 colors[role as ThemeColorRole] = normalized;
185194 }
186195 }
187196 return colors as ThemeColors;
......@@ -226,26 +235,43 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
226235 };
227236}
228237
229function readCustomThemesFromStorage(): ReadonlyArray<ThemeDefinition> {
230 if (typeof window === "undefined") return [];
238function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {
239 if (typeof window === "undefined") {
240 return { status: "ready", storedThemes: [], themes: [] };
241 }
231242
243 let raw: string | null;
232244 try {
233 const raw = window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY);
234 if (!raw) return [];
235 const parsed: unknown = JSON.parse(raw);
236 if (!Array.isArray(parsed)) return [];
237
238 const themes: ThemeDefinition[] = [];
239 for (const value of parsed) {
240 const theme = parseStoredTheme(value);
241 if (theme && !themes.some((existing) => existing.id === theme.id)) {
242 themes.push(theme);
243 }
244 }
245 return themes;
245 raw = window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY);
246 } catch (cause) {
247 return { status: "unavailable", reason: "storage-unavailable", cause };
248 }
249 if (!raw) return { status: "ready", storedThemes: [], themes: [] };
250
251 let parsed: unknown;
252 try {
253 parsed = JSON.parse(raw);
246254 } catch {
247 return [];
255 return { status: "unavailable", reason: "malformed" };
248256 }
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;
249275}
250276
251277function notifyCustomThemeListeners() {
......@@ -253,15 +279,13 @@ function notifyCustomThemeListeners() {
253279}
254280
255281export function invalidateCustomThemes() {
256 customThemesSnapshot = null;
282 customThemeLibrarySnapshot = null;
257283 notifyCustomThemeListeners();
258284}
259285
260286export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {
261 if (customThemesSnapshot === null) {
262 customThemesSnapshot = readCustomThemesFromStorage();
263 }
264 return customThemesSnapshot;
287 const snapshot = getCustomThemeLibrarySnapshot();
288 return snapshot.status === "ready" ? snapshot.themes : [];
265289}
266290
267291export function subscribeToCustomThemes(listener: () => void): () => void {
......@@ -470,7 +494,7 @@ const T3_CHAT_DARK_COLORS: ThemeColors = {
470494 * stock tokens (index.css) so a draft seeded from the default look paints the
471495 * pixels the user is already seeing. Alpha-bearing tokens are flattened over
472496 * 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.
474498 */
475499const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = {
476500 canvas: "#fcfcfc",
......@@ -599,7 +623,10 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = {
599623 * files.
600624 */
601625export 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));
603630}
604631
605632type ThemeRgbColor = {
......@@ -614,34 +641,81 @@ type ThemeHslColor = {
614641 l: number;
615642};
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
617650const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 };
618651const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 };
619652const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 };
620653const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 };
621654
622function parseThemeRgbColor(value: string, fallback: ThemeRgbColor): ThemeRgbColor {
623 const match = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
624 if (!match) return fallback;
625
626 const raw = match[1];
627 if (!raw) return fallback;
628 const hex =
629 raw.length <= 4
630 ? raw
631 .slice(0, 3)
632 .split("")
633 .map((part) => part.repeat(2))
634 .join("")
635 : raw.slice(0, 6);
636 if (hex.length !== 6) return fallback;
637
655const convertToOklch = converter("oklch");
656
657function parseThemeColor(value: unknown): ParsedThemeColor | null {
658 if (typeof value !== "string") return null;
659 const input = value.trim();
660 const parsed = parse(input);
661 if (!parsed) return null;
662 const color = convertToOklch(parsed);
663 const lightness = color.l ?? 0;
664 const chroma = color.c ?? 0;
665 const hue = color.h ?? 0;
666 // CSS missing components behave as zero outside interpolation. Culori omits
667 // a `none` alpha from its parsed object, so distinguish it from omitted alpha.
668 const alpha = /\/\s*none\s*\)$/i.test(input) ? 0 : (color.alpha ?? 1);
669 if (![lightness, chroma, hue, alpha].every(Number.isFinite)) return null;
638670 return {
639 r: Number.parseInt(hex.slice(0, 2), 16),
640 g: Number.parseInt(hex.slice(2, 4), 16),
641 b: Number.parseInt(hex.slice(4, 6), 16),
671 color: {
672 L: Math.min(1, Math.max(0, lightness)),
673 C: Math.max(0, chroma),
674 h: hue,
675 },
676 alpha: Math.min(1, Math.max(0, alpha)),
642677 };
643678}
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
645719function themeRgbToHexColor(color: ThemeRgbColor): string {
646720 return `#${[color.r, color.g, color.b]
647721 .map((channel) =>
......@@ -652,6 +726,41 @@ function themeRgbToHexColor(color: ThemeRgbColor): string {
652726 .join("")}`;
653727}
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
655764function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor {
656765 const red = color.r / 255;
657766 const green = color.g / 255;
......@@ -717,10 +826,6 @@ function themeRelativeLuminance(color: ThemeRgbColor): number {
717826
718827// ---------------------------------------------------------------------------
719828// 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
725830function srgbChannelToLinear(channel: number): number {
726831 const c = channel / 255;
......@@ -759,24 +864,30 @@ function oklchToRgbUnclamped({ L, C, h }: ThemeOklch): { r: number; g: number; b
759864 };
760865}
761866
762/** Convert to sRGB, walking chroma toward grey until the color is in gamut. */
763function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor {
764 let { C } = color;
765 for (let step = 0; step < 12; step += 1) {
867/** Find the greatest chroma along the same lightness and hue that fits in sRGB. */
868function mapThemeOklchToSrgbGamut(color: ThemeOklch): ThemeOklch {
869 const isInGamut = (C: number) => {
766870 const linear = oklchToRgbUnclamped({ ...color, C });
767 const inGamut = [linear.r, linear.g, linear.b].every(
871 return [linear.r, linear.g, linear.b].every(
768872 (channel) => channel >= -0.0001 && channel <= 1.0001,
769873 );
770 if (inGamut) {
771 return {
772 r: linearChannelToSrgb(linear.r),
773 g: linearChannelToSrgb(linear.g),
774 b: linearChannelToSrgb(linear.b),
775 };
776 }
777 C *= 0.82;
874 };
875 if (isInGamut(color.C)) return color;
876
877 let low = 0;
878 let high = color.C;
879 const steps = Math.max(1, Math.ceil(Math.log2(Math.max(color.C, 0.000001) / 0.000001)));
880 for (let step = 0; step < steps; step += 1) {
881 const mid = (low + high) / 2;
882 if (isInGamut(mid)) low = mid;
883 else high = mid;
778884 }
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));
780891 return {
781892 r: linearChannelToSrgb(linear.r),
782893 g: linearChannelToSrgb(linear.g),
......@@ -784,6 +895,10 @@ function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor {
784895 };
785896}
786897
898function themeOklchToThemeColor(color: ThemeOklch): string {
899 return formatOklchThemeColor(mapThemeOklchToSrgbGamut(color));
900}
901
787902/** Binary-search the lightness that reaches the contrast target against a background. */
788903function solveOklchLightness(
789904 base: ThemeOklch,
......@@ -856,26 +971,24 @@ function standardStatusColors(canvas: ThemeRgbColor): {
856971 // tinted one they can fall just short, so lightness is nudged until the
857972 // pair clears 4.5 while the hue stays standard.
858973 const readableOn = (foreground: string, surface: ThemeRgbColor) =>
859 themeRgbToHexColor(
860 themeOklchToRgb(
861 solveOklchLightness(
862 themeRgbToOklch(parseThemeRgbColor(foreground, canvas)),
863 surface,
864 // A hair above 4.5: the solve happens in OKLCH and the result is
865 // quantized to 8-bit hex, which can shave the last hundredth off.
866 4.6,
867 appearance === "dark" ? "lighter" : "darker",
868 ),
974 themeOklchToThemeColor(
975 solveOklchLightness(
976 themeRgbToOklch(parseThemeRgbColor(foreground, canvas)),
977 surface,
978 // Leave a little headroom for browser color conversion at render time.
979 4.6,
980 appearance === "dark" ? "lighter" : "darker",
869981 ),
870982 );
871983 const errorSurface = surfaceOf(standard.error);
872984 const warningSurface = surfaceOf(standard.warning);
873985 return {
874 ...standard,
986 error: toCanonicalThemeColor(standard.error)!,
875987 errorForeground: readableOn(standard.errorForeground, errorSurface),
876 errorSurface: themeRgbToHexColor(errorSurface),
988 errorSurface: themeRgbToThemeColor(errorSurface),
989 warning: toCanonicalThemeColor(standard.warning)!,
877990 warningForeground: readableOn(standard.warningForeground, warningSurface),
878 warningSurface: themeRgbToHexColor(warningSurface),
991 warningSurface: themeRgbToThemeColor(warningSurface),
879992 };
880993}
881994
......@@ -912,7 +1025,7 @@ export function createVividThemeColors(
9121025 C: chroma,
9131026 h: hue,
9141027 });
915 const hex = (color: ThemeOklch) => themeRgbToHexColor(themeOklchToRgb(color));
1028 const themeColor = (color: ThemeOklch) => themeOklchToThemeColor(color);
9161029
9171030 // Text carries a whisper of the accent hue instead of falling back to a
9181031 // fixed foreground, and is solved to WCAG AAA against the canvas.
......@@ -956,8 +1069,8 @@ export function createVividThemeColors(
9561069 const updateSurface = surfaceAt(dark ? 0.14 : 0.09, Math.min(0.12, accent.C * 0.55));
9571070
9581071 const foregroundOn = (surfaceRgb: ThemeRgbColor): string =>
959 themeRgbToHexColor(
960 themeOklchToRgb(solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker")),
1072 themeOklchToThemeColor(
1073 solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker"),
9611074 );
9621075 const mutedForeground = foregroundOn(mutedRgb);
9631076 const placeholder = foregroundOn(surfaceRaisedRgb);
......@@ -967,58 +1080,58 @@ export function createVividThemeColors(
9671080 return {
9681081 ...defaults,
9691082 ...standardStatusColors(canvasRgb),
970 canvas: themeRgbToHexColor(canvasRgb),
1083 canvas: themeRgbToThemeColor(canvasRgb),
9711084 // The top bar shares the canvas so the main panel reads as one surface.
972 chrome: themeRgbToHexColor(canvasRgb),
973 toolbar: themeRgbToHexColor(canvasRgb),
974 toolbarForeground: themeRgbToHexColor(textRgb),
975 toolbarBorder: hex(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)),
977 toolbarControlForeground: themeRgbToHexColor(textRgb),
978 toolbarControlHover: hex(surfaceAt(dark ? 0.14 : 0.09, tintC * 1.6)),
979 surface: hex(surface),
980 surfaceRaised: hex(surfaceRaised),
981 surfaceOverlay: hex(surfaceOverlay),
982 text: themeRgbToHexColor(textRgb),
983 textMuted: themeRgbToHexColor(textMutedRgb),
984 border: hex(border),
985 input: hex(input),
986 focus: themeRgbToHexColor(accentRgb),
987 accent: themeRgbToHexColor(accentRgb),
988 accentForeground: themeRgbToHexColor(accentForeground),
989 secondary: hex(secondary),
1085 chrome: themeRgbToThemeColor(canvasRgb),
1086 toolbar: themeRgbToThemeColor(canvasRgb),
1087 toolbarForeground: themeRgbToThemeColor(textRgb),
1088 toolbarBorder: themeColor(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.08, accent.C * 0.4))),
1089 toolbarControl: themeColor(surfaceAt(dark ? 0.09 : 0.05, tintC * 1.3)),
1090 toolbarControlForeground: themeRgbToThemeColor(textRgb),
1091 toolbarControlHover: themeColor(surfaceAt(dark ? 0.14 : 0.09, tintC * 1.6)),
1092 surface: themeColor(surface),
1093 surfaceRaised: themeColor(surfaceRaised),
1094 surfaceOverlay: themeColor(surfaceOverlay),
1095 text: themeRgbToThemeColor(textRgb),
1096 textMuted: themeRgbToThemeColor(textMutedRgb),
1097 border: themeColor(border),
1098 input: themeColor(input),
1099 focus: themeRgbToThemeColor(accentRgb),
1100 accent: themeRgbToThemeColor(accentRgb),
1101 accentForeground: themeRgbToThemeColor(accentForeground),
1102 secondary: themeColor(secondary),
9901103 secondaryForeground: foregroundOn(secondaryRgb),
991 muted: hex(muted),
1104 muted: themeColor(muted),
9921105 mutedForeground,
9931106 placeholder,
994 secondaryLabel: themeRgbToHexColor(textMutedRgb),
995 iconMuted: themeRgbToHexColor(textMutedRgb),
996 update: themeRgbToHexColor(accentRgb),
1107 secondaryLabel: themeRgbToThemeColor(textMutedRgb),
1108 iconMuted: themeRgbToThemeColor(textMutedRgb),
1109 update: themeRgbToThemeColor(accentRgb),
9971110 updateForeground: foregroundOn(themeOklchToRgb(updateSurface)),
998 updateSurface: hex(updateSurface),
999 accentSurface: hex(accentSurface),
1111 updateSurface: themeColor(updateSurface),
1112 accentSurface: themeColor(accentSurface),
10001113 accentSurfaceForeground: foregroundOn(accentSurfaceRgb),
1001 messageSurface: hex(messageSurface),
1114 messageSurface: themeColor(messageSurface),
10021115 messageForeground: foregroundOn(messageSurfaceRgb),
1003 messageAction: themeRgbToHexColor(actionRgb),
1004 messageActionForeground: themeRgbToHexColor(actionForeground),
1005 messageActionHover: hex(actionHover),
1006 codeBackground: hex(codeBackground),
1007 codeForeground: themeRgbToHexColor(textRgb),
1008 sidebar: hex(sidebar),
1116 messageAction: themeRgbToThemeColor(actionRgb),
1117 messageActionForeground: themeRgbToThemeColor(actionForeground),
1118 messageActionHover: themeColor(actionHover),
1119 codeBackground: themeColor(codeBackground),
1120 codeForeground: themeRgbToThemeColor(textRgb),
1121 sidebar: themeColor(sidebar),
10091122 sidebarForeground: foregroundOn(sidebarRgb),
1010 sidebarMutedForeground: themeRgbToHexColor(standardMutedThemeText(sidebarRgb, textRgb)),
1011 sidebarControlSurface: hex(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))),
1013 sidebarRowActive: hex(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))),
1015 sidebarBorder: hex(surfaceAt(dark ? 0.17 : 0.12, Math.min(0.08, accent.C * 0.4))),
1016 terminalBackground: themeRgbToHexColor(canvasRgb),
1017 terminalForeground: themeRgbToHexColor(textRgb),
1018 terminalCursor: themeRgbToHexColor(accentRgb),
1019 terminalSelection: hex(surfaceAt(dark ? 0.18 : 0.12, Math.min(0.12, accent.C * 0.55))),
1020 terminalScrollbar: hex(surfaceAt(dark ? 0.22 : 0.16, tintC)),
1021 terminalScrollbarHover: hex(surfaceAt(dark ? 0.3 : 0.22, tintC)),
1123 sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebarRgb, textRgb)),
1124 sidebarControlSurface: themeColor(surfaceAt(dark ? 0.1 : 0.07, tintC * 1.5)),
1125 sidebarRowHover: themeColor(surfaceAt(dark ? 0.08 : 0.06, Math.min(0.08, accent.C * 0.45))),
1126 sidebarRowActive: themeColor(surfaceAt(dark ? 0.12 : 0.09, Math.min(0.1, accent.C * 0.55))),
1127 sidebarRowSelected: themeColor(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.11, accent.C * 0.6))),
1128 sidebarBorder: themeColor(surfaceAt(dark ? 0.17 : 0.12, Math.min(0.08, accent.C * 0.4))),
1129 terminalBackground: themeRgbToThemeColor(canvasRgb),
1130 terminalForeground: themeRgbToThemeColor(textRgb),
1131 terminalCursor: themeRgbToThemeColor(accentRgb),
1132 terminalSelection: themeColor(surfaceAt(dark ? 0.18 : 0.12, Math.min(0.12, accent.C * 0.55))),
1133 terminalScrollbar: themeColor(surfaceAt(dark ? 0.22 : 0.16, tintC)),
1134 terminalScrollbarHover: themeColor(surfaceAt(dark ? 0.3 : 0.22, tintC)),
10221135 };
10231136}
10241137
......@@ -1127,8 +1240,7 @@ function managedThemeAccent(
11271240 const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness });
11281241 return { color, lightness, contrast: themeContrastRatio(color, background) };
11291242 });
1130 // Leave a little room for rounding when the generated RGB values become a
1131 // six-digit hex token.
1243 // Leave a little room for browser color conversion at render time.
11321244 const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7);
11331245 const pool = readableCandidates.length > 0 ? readableCandidates : candidates;
11341246
......@@ -1210,80 +1322,80 @@ export function createManagedThemeColors(
12101322 return {
12111323 ...defaults,
12121324 ...standardStatusColors(canvas),
1213 update: themeRgbToHexColor(accent),
1214 updateForeground: themeRgbToHexColor(updateForeground),
1215 updateSurface: themeRgbToHexColor(updateSurface),
1216 canvas: themeRgbToHexColor(canvas),
1217 chrome: themeRgbToHexColor(chrome),
1218 toolbar: themeRgbToHexColor(chrome),
1219 toolbarForeground: themeRgbToHexColor(text),
1220 toolbarBorder: themeRgbToHexColor(toolbarBorder),
1221 toolbarControl: themeRgbToHexColor(toolbarControl),
1222 toolbarControlForeground: themeRgbToHexColor(text),
1223 toolbarControlHover: themeRgbToHexColor(accentSurface),
1224 surface: themeRgbToHexColor(canvas),
1225 surfaceRaised: themeRgbToHexColor(surfaceRaised),
1226 surfaceOverlay: themeRgbToHexColor(surfaceOverlay),
1227 text: themeRgbToHexColor(text),
1228 textMuted: themeRgbToHexColor(textMuted),
1325 update: themeRgbToThemeColor(accent),
1326 updateForeground: themeRgbToThemeColor(updateForeground),
1327 updateSurface: themeRgbToThemeColor(updateSurface),
1328 canvas: themeRgbToThemeColor(canvas),
1329 chrome: themeRgbToThemeColor(chrome),
1330 toolbar: themeRgbToThemeColor(chrome),
1331 toolbarForeground: themeRgbToThemeColor(text),
1332 toolbarBorder: themeRgbToThemeColor(toolbarBorder),
1333 toolbarControl: themeRgbToThemeColor(toolbarControl),
1334 toolbarControlForeground: themeRgbToThemeColor(text),
1335 toolbarControlHover: themeRgbToThemeColor(accentSurface),
1336 surface: themeRgbToThemeColor(canvas),
1337 surfaceRaised: themeRgbToThemeColor(surfaceRaised),
1338 surfaceOverlay: themeRgbToThemeColor(surfaceOverlay),
1339 text: themeRgbToThemeColor(text),
1340 textMuted: themeRgbToThemeColor(textMuted),
12291341 // Borders blend through the accent before lightening so control chrome
12301342 // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1)
12311343 // instead of flattening to grey.
1232 border: themeRgbToHexColor(
1344 border: themeRgbToThemeColor(
12331345 mixThemeRgbColors(
12341346 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1),
12351347 text,
12361348 0.1,
12371349 ),
12381350 ),
1239 input: themeRgbToHexColor(
1351 input: themeRgbToThemeColor(
12401352 mixThemeRgbColors(
12411353 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14),
12421354 text,
12431355 appearance === "dark" ? 0.14 : 0.13,
12441356 ),
12451357 ),
1246 focus: themeRgbToHexColor(accent),
1247 accent: themeRgbToHexColor(accent),
1248 accentForeground: themeRgbToHexColor(accentForeground),
1249 secondary: themeRgbToHexColor(secondary),
1250 secondaryForeground: themeRgbToHexColor(readableThemeForeground(secondary)),
1251 muted: themeRgbToHexColor(muted),
1252 mutedForeground: themeRgbToHexColor(mutedForeground),
1253 placeholder: themeRgbToHexColor(placeholder),
1254 secondaryLabel: themeRgbToHexColor(textMuted),
1255 iconMuted: themeRgbToHexColor(textMuted),
1256 accentSurface: themeRgbToHexColor(accentSurface),
1257 accentSurfaceForeground: themeRgbToHexColor(readableThemeForeground(accentSurface)),
1258 messageSurface: themeRgbToHexColor(messageSurface),
1259 messageForeground: themeRgbToHexColor(readableThemeForeground(messageSurface)),
1260 messageAction: themeRgbToHexColor(accent),
1261 messageActionForeground: themeRgbToHexColor(accentForeground),
1262 messageActionHover: themeRgbToHexColor(messageActionHover),
1263 codeBackground: themeRgbToHexColor(codeBackground),
1264 codeForeground: themeRgbToHexColor(readableThemeForeground(codeBackground)),
1265 sidebar: themeRgbToHexColor(sidebar),
1266 sidebarForeground: themeRgbToHexColor(readableThemeForeground(sidebar)),
1267 sidebarMutedForeground: themeRgbToHexColor(standardMutedThemeText(sidebar, text)),
1268 sidebarControlSurface: themeRgbToHexColor(
1358 focus: themeRgbToThemeColor(accent),
1359 accent: themeRgbToThemeColor(accent),
1360 accentForeground: themeRgbToThemeColor(accentForeground),
1361 secondary: themeRgbToThemeColor(secondary),
1362 secondaryForeground: themeRgbToThemeColor(readableThemeForeground(secondary)),
1363 muted: themeRgbToThemeColor(muted),
1364 mutedForeground: themeRgbToThemeColor(mutedForeground),
1365 placeholder: themeRgbToThemeColor(placeholder),
1366 secondaryLabel: themeRgbToThemeColor(textMuted),
1367 iconMuted: themeRgbToThemeColor(textMuted),
1368 accentSurface: themeRgbToThemeColor(accentSurface),
1369 accentSurfaceForeground: themeRgbToThemeColor(readableThemeForeground(accentSurface)),
1370 messageSurface: themeRgbToThemeColor(messageSurface),
1371 messageForeground: themeRgbToThemeColor(readableThemeForeground(messageSurface)),
1372 messageAction: themeRgbToThemeColor(accent),
1373 messageActionForeground: themeRgbToThemeColor(accentForeground),
1374 messageActionHover: themeRgbToThemeColor(messageActionHover),
1375 codeBackground: themeRgbToThemeColor(codeBackground),
1376 codeForeground: themeRgbToThemeColor(readableThemeForeground(codeBackground)),
1377 sidebar: themeRgbToThemeColor(sidebar),
1378 sidebarForeground: themeRgbToThemeColor(readableThemeForeground(sidebar)),
1379 sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebar, text)),
1380 sidebarControlSurface: themeRgbToThemeColor(
12691381 mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08),
12701382 ),
1271 sidebarRowHover: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.12)),
1272 sidebarRowActive: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.2)),
1273 sidebarRowSelected: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.24)),
1274 sidebarBorder: themeRgbToHexColor(
1383 sidebarRowHover: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.12)),
1384 sidebarRowActive: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.2)),
1385 sidebarRowSelected: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.24)),
1386 sidebarBorder: themeRgbToThemeColor(
12751387 mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12),
12761388 ),
1277 terminalBackground: themeRgbToHexColor(terminalBackground),
1278 terminalForeground: themeRgbToHexColor(readableThemeForeground(terminalBackground)),
1279 terminalCursor: themeRgbToHexColor(accent),
1280 terminalSelection: themeRgbToHexColor(
1389 terminalBackground: themeRgbToThemeColor(terminalBackground),
1390 terminalForeground: themeRgbToThemeColor(readableThemeForeground(terminalBackground)),
1391 terminalCursor: themeRgbToThemeColor(accent),
1392 terminalSelection: themeRgbToThemeColor(
12811393 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18),
12821394 ),
1283 terminalScrollbar: themeRgbToHexColor(
1395 terminalScrollbar: themeRgbToThemeColor(
12841396 mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22),
12851397 ),
1286 terminalScrollbarHover: themeRgbToHexColor(
1398 terminalScrollbarHover: themeRgbToThemeColor(
12871399 mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32),
12881400 ),
12891401 };
......@@ -1293,16 +1405,16 @@ export const T3_CHAT_THEME: ThemeDefinition = {
12931405 id: T3_CHAT_THEME_ID,
12941406 label: T3_CHAT_THEME_LABEL,
12951407 appearance: "light",
1296 colors: T3_CHAT_LIGHT_COLORS,
1408 colors: decodeThemeColors(T3_CHAT_LIGHT_COLORS),
12971409 variants: {
1298 dark: T3_CHAT_DARK_COLORS,
1410 dark: decodeThemeColors(T3_CHAT_DARK_COLORS),
12991411 },
13001412 sidebarArtwork: true,
13011413};
13021414
13031415/** Theme-file defaults follow the flagship palette for the requested mode. */
13041416export 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;
13061418}
13071419
13081420/**
......@@ -1320,9 +1432,9 @@ function themeActionColors(
13201432 ? THEME_BLACK_FOREGROUND
13211433 : THEME_WHITE_FOREGROUND;
13221434 return {
1323 messageAction: action,
1324 messageActionForeground: themeRgbToHexColor(foreground),
1325 messageActionHover: themeRgbToHexColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)),
1435 messageAction: toCanonicalThemeColor(action) ?? themeRgbToThemeColor(rgb),
1436 messageActionForeground: themeRgbToThemeColor(foreground),
1437 messageActionHover: themeRgbToThemeColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)),
13261438 };
13271439}
13281440
......@@ -1452,39 +1564,72 @@ export function themeIdFromName(name: string): string {
14521564
14531565export class ThemeLibraryStorageError extends Schema.TaggedErrorClass<ThemeLibraryStorageError>()(
14541566 "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 },
14561573) {
14571574 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}.`;
14591577 }
14601578}
14611579
14621580export const isThemeLibraryStorageError = Schema.is(ThemeLibraryStorageError);
14631581
1464function saveCustomThemes(themes: ReadonlyArray<ThemeDefinition>): void {
1582function saveCustomThemes(
1583 storedThemes: ReadonlyArray<unknown>,
1584 themes: ReadonlyArray<ThemeDefinition>,
1585): void {
14651586 if (typeof window === "undefined") return;
14661587 try {
1467 window.localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify(themes));
1468 customThemesSnapshot = themes;
1588 window.localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify(storedThemes));
1589 customThemeLibrarySnapshot = { status: "ready", storedThemes, themes };
14691590 } 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 });
14711597 }
14721598 notifyCustomThemeListeners();
14731599}
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
14751618export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition {
14761619 if (RESERVED_THEME_IDS.has(theme.id)) {
14771620 throw new Error(`The theme id "${theme.id}" is reserved.`);
14781621 }
1622 const library = getWritableCustomThemeLibrary();
14791623 if (
1480 [...BUILT_IN_THEME_DEFINITIONS, ...getCustomThemes()].some(
1481 (existing) => existing.id === theme.id,
1482 )
1624 BUILT_IN_THEME_DEFINITIONS.some((existing) => existing.id === theme.id) ||
1625 library.storedThemes.some((storedTheme) => storedThemeHasId(storedTheme, theme.id))
14831626 ) {
14841627 throw new Error(`A theme named "${theme.label}" is already installed.`);
14851628 }
1486 saveCustomThemes([...getCustomThemes(), theme]);
1487 return theme;
1629 const canonicalTheme = canonicalizeThemeDefinition(theme);
1630 const themes = [...library.themes, canonicalTheme];
1631 saveCustomThemes([...library.storedThemes, canonicalTheme], themes);
1632 return canonicalTheme;
14881633}
14891634
14901635export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition {
......@@ -1492,22 +1637,39 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition {
14921637 throw new Error(`The theme id "${theme.id}" is reserved.`);
14931638 }
14941639
1495 const themes = getCustomThemes();
1640 const library = getWritableCustomThemeLibrary();
1641 const themes = library.themes;
14961642 const themeIndex = themes.findIndex((existing) => existing.id === theme.id);
14971643 if (themeIndex === -1) {
14981644 throw new Error(`The theme "${theme.label}" is not installed.`);
14991645 }
15001646
1647 const canonicalTheme = canonicalizeThemeDefinition(theme);
15011648 const nextThemes = [...themes];
1502 nextThemes[themeIndex] = theme;
1503 saveCustomThemes(nextThemes);
1504 return theme;
1649 nextThemes[themeIndex] = canonicalTheme;
1650
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;
15051663}
15061664
15071665export function removeCustomTheme(themeId: string): void {
1508 const nextThemes = getCustomThemes().filter((theme) => theme.id !== themeId);
1509 if (nextThemes.length === getCustomThemes().length) return;
1510 saveCustomThemes(nextThemes);
1666 const library = getWritableCustomThemeLibrary();
1667 const nextThemes = library.themes.filter((theme) => theme.id !== themeId);
1668 if (nextThemes.length === library.themes.length) return;
1669 saveCustomThemes(
1670 library.storedThemes.filter((storedTheme) => !storedThemeHasId(storedTheme, themeId)),
1671 nextThemes,
1672 );
15111673}
15121674
15131675function parseThemeColorOverrides(value: unknown): ThemeColorOverrides {
......@@ -1518,10 +1680,13 @@ function parseThemeColorOverrides(value: unknown): ThemeColorOverrides {
15181680 if (!THEME_COLOR_ROLE_SET.has(role)) {
15191681 throw new Error(`"${role}" is not a supported theme color role.`);
15201682 }
1521 if (!isThemeColor(color)) {
1522 throw new Error(`The color for "${role}" must be a hex color such as #8b5cf6.`);
1683 const normalized = toCanonicalThemeColor(color);
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 );
15231688 }
1524 overrides[role as ThemeColorRole] = color;
1689 overrides[role as ThemeColorRole] = normalized;
15251690 }
15261691 if (Object.keys(overrides).length === 0) {
15271692 throw new Error("Add at least one color role to the theme file.");
......@@ -1586,14 +1751,15 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
15861751}
15871752
15881753export function serializeThemeFile(theme: ThemeDefinition): string {
1754 const canonicalTheme = canonicalizeThemeDefinition(theme);
15891755 const file: ThemeFile = {
15901756 version: THEME_FILE_VERSION,
1591 id: theme.id,
1592 name: theme.label,
1593 appearance: theme.appearance,
1594 colors: theme.colors,
1595 ...(theme.variants ? { variants: theme.variants } : {}),
1596 ...(theme.managed ? { managed: true } : {}),
1757 id: canonicalTheme.id,
1758 name: canonicalTheme.label,
1759 appearance: canonicalTheme.appearance,
1760 colors: canonicalTheme.colors,
1761 ...(canonicalTheme.variants ? { variants: canonicalTheme.variants } : {}),
1762 ...(canonicalTheme.managed ? { managed: true } : {}),
15971763 };
15981764 return `${JSON.stringify(file, null, 2)}\n`;
15991765}
apps/web/src/vscodeThemeImport.test.ts+26-19
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { getThemeColorsForMode, THEME_FILE_VERSION } from "./themePalette";
3import { getThemeColorsForMode, themeColorToHex, THEME_FILE_VERSION } from "./themePalette";
44import {
55 isVsCodeThemeFile,
66 pairVsCodeThemes,
......@@ -8,9 +8,15 @@ import {
88 resolveThemeLabelCollisions,
99} 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
1117function contrastRatio(first: string, second: string): number {
1218 const toChannels = (value: string) => {
13 const hex = value.slice(1);
19 const hex = asHex(value).slice(1);
1420 return [0, 1, 2].map(
1521 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
1622 );
......@@ -69,18 +75,18 @@ describe("VS Code theme import", () => {
6975 // The slug name is read as words; a displayName would win verbatim.
7076 expect(theme.label).toBe("Pierre Dark Soft");
7177 expect(theme.appearance).toBe("dark");
72 expect(theme.colors.canvas).toBe("#171717");
73 expect(theme.colors.text).toBe("#d4d4d4");
74 expect(theme.colors.accent).toBe("#69b1ff");
75 expect(theme.colors.sidebar).toBe("#101010");
76 expect(theme.colors.terminalBackground).toBe("#101010");
78 expect(asHex(theme.colors.canvas)).toBe("#171717");
79 expect(asHex(theme.colors.text)).toBe("#d4d4d4");
80 expect(asHex(theme.colors.accent)).toBe("#69b1ff");
81 expect(asHex(theme.colors.sidebar)).toBe("#101010");
82 expect(asHex(theme.colors.terminalBackground)).toBe("#101010");
7783 });
7884
7985 it("flattens alpha overlays onto the surface they sit on", () => {
8086 const theme = parseVsCodeThemeFile(VSCODE_DARK);
8187 // #1f3e5e59 over the #101010 sidebar, not left semi-transparent.
82 expect(theme.colors.sidebarRowHover).toMatch(/^#[0-9a-f]{6}$/);
83 expect(theme.colors.sidebarRowHover).not.toBe("#1f3e5e59");
88 expect(theme.colors.sidebarRowHover).toMatch(/^oklch\(/);
89 expect(asHex(theme.colors.sidebarRowHover)).not.toBe("#1f3e5e59");
8490 expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar);
8591 });
8692
......@@ -88,7 +94,7 @@ describe("VS Code theme import", () => {
8894 const theme = parseVsCodeThemeFile(VSCODE_DARK);
8995 const colors = getThemeColorsForMode(theme, "dark")!;
9096 for (const value of Object.values(colors)) {
91 expect(value).toMatch(/^#[0-9a-f]{3,8}$/i);
97 expect(value).toMatch(/^oklch\(/);
9298 }
9399 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(4.5);
94100 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);
......@@ -117,7 +123,7 @@ describe("VS Code theme import", () => {
117123 type: "dark",
118124 colors: { "editor.background": "#101010", "editor.foreground": "#111111" },
119125 });
120 expect(theme.colors.text).not.toBe("#111111");
126 expect(asHex(theme.colors.text)).not.toBe("#111111");
121127 expect(contrastRatio(theme.colors.text, theme.colors.canvas)).toBeGreaterThanOrEqual(4.5);
122128 });
123129
......@@ -134,7 +140,7 @@ describe("VS Code theme import", () => {
134140 "terminal.background": "#fbfbfb",
135141 },
136142 });
137 expect(theme.colors.sidebar).toBe("#fafafa");
143 expect(asHex(theme.colors.sidebar)).toBe("#fafafa");
138144 expect(
139145 contrastRatio(theme.colors.sidebarForeground, theme.colors.sidebar),
140146 ).toBeGreaterThanOrEqual(4.5);
......@@ -156,10 +162,10 @@ describe("VS Code theme import", () => {
156162 "editor.selectionBackground": "color(display-p3 0.308664 0.645271 1.000000 / 0.300000)",
157163 },
158164 });
159 expect(theme.colors.canvas).toMatch(/^#0[89ab]/);
160 expect(theme.colors.text).toMatch(/^#f[a-f0-9]/);
165 expect(asHex(theme.colors.canvas)).toMatch(/^#0[89ab]/);
166 expect(asHex(theme.colors.text)).toMatch(/^#f[a-f0-9]/);
161167 // 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);
163169 const [red, green, blue] = [1, 3, 5].map((index) =>
164170 Number.parseInt(accent.slice(index, index + 2), 16),
165171 ) as [number, number, number];
......@@ -192,8 +198,8 @@ describe("VS Code theme import", () => {
192198 const github = themes[0]!;
193199 expect(github.appearance).toBe("light");
194200 expect(getThemeColorsForMode(github, "dark")).not.toBeNull();
195 expect(getThemeColorsForMode(github, "dark")!.canvas).toBe("#101014");
196 expect(github.colors.canvas).toBe("#fdfdfd");
201 expect(asHex(getThemeColorsForMode(github, "dark")!.canvas)).toBe("#101014");
202 expect(asHex(github.colors.canvas)).toBe("#fdfdfd");
197203 // The unpaired dimmed variant stays a single dark theme.
198204 expect(getThemeColorsForMode(themes[2]!, "light")).toBeNull();
199205 });
......@@ -220,14 +226,15 @@ describe("VS Code theme import", () => {
220226 // surface, plain surfaces) must stay near the canvas, not turn blue.
221227 const theme = parseVsCodeThemeFile(VSCODE_DARK);
222228 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));
224231 return Math.max(...channels) - Math.min(...channels);
225232 };
226233 expect(spread(theme.colors.codeBackground)).toBeLessThanOrEqual(8);
227234 expect(spread(theme.colors.surface)).toBeLessThanOrEqual(8);
228235 expect(spread(theme.colors.text)).toBeLessThanOrEqual(12);
229236 // The accent itself keeps the file's color.
230 expect(theme.colors.accent).toBe("#69b1ff");
237 expect(asHex(theme.colors.accent)).toBe("#69b1ff");
231238 });
232239
233240 it("tells same-named variants apart by their file names", () => {
apps/web/src/vscodeThemeImport.ts+2-1
......@@ -2,6 +2,7 @@ import {
22 createVividThemeColors,
33 getThemeModes,
44 parseThemeFile,
5 themeColorToHex,
56 THEME_FILE_VERSION,
67 type ThemeAppearance,
78 type ThemeColorRole,
......@@ -138,7 +139,7 @@ function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number {
138139}
139140
140141function 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 };
142143}
143144
144145/**
pnpm-lock.yaml+11
......@@ -570,6 +570,9 @@ importers:
570570 class-variance-authority:
571571 specifier: ^0.7.1
572572 version: 0.7.1
573 culori:
574 specifier: ^4.0.2
575 version: 4.0.2
573576 effect:
574577 specifier: 4.0.0-beta.103
575578 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
......@@ -631,6 +634,9 @@ importers:
631634 '@types/compression':
632635 specifier: ^1.8.1
633636 version: 1.8.1
637 '@types/culori':
638 specifier: ^4.0.1
639 version: 4.0.1
634640 '@types/react':
635641 specifier: ~19.2.14
636642 version: 19.2.16
......@@ -4794,6 +4800,9 @@ packages:
47944800 '@types/connect@3.4.38':
47954801 resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
47964802
4803 '@types/culori@4.0.1':
4804 resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==}
4805
47974806 '@types/debug@4.1.13':
47984807 resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
47994808
......@@ -14972,6 +14981,8 @@ snapshots:
1497214981 dependencies:
1497314982 '@types/node': 24.12.4
1497414983
14984 '@types/culori@4.0.1': {}
14985
1497514986 '@types/debug@4.1.13':
1497614987 dependencies:
1497714988 '@types/ms': 2.1.0