| author | |
| committer | |
| log | 083fa4ab24c464ddf01e5b7ab22135d1ebdc120b |
| tree | 0190e499a2b2d86cd08ba213376b35b2317df56c |
| parent | 35172010b131510d36d0cef54e174926e38a3013 |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 978 insertions(+), 480 deletions(-)
apps/web/index.html+72-55| ... | ... | @@ -35,16 +35,16 @@ |
| 35 | 35 | // app colors before React mounts. |
| 36 | 36 | const DEFAULT_THEME_PALETTES = { |
| 37 | 37 | 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)", | |
| 42 | 42 | }, |
| 43 | 43 | 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)", | |
| 48 | 48 | }, |
| 49 | 49 | }; |
| 50 | 50 | // Keep this small boot-time copy in sync with the built-in palettes so |
| ... | ... | @@ -52,72 +52,72 @@ |
| 52 | 52 | const BUILT_IN_THEME_PALETTES = { |
| 53 | 53 | "t3-chat": { |
| 54 | 54 | 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)", | |
| 59 | 59 | }, |
| 60 | 60 | 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)", | |
| 65 | 65 | }, |
| 66 | 66 | }, |
| 67 | 67 | grove: { |
| 68 | 68 | 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)", | |
| 73 | 73 | }, |
| 74 | 74 | 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)", | |
| 79 | 79 | }, |
| 80 | 80 | }, |
| 81 | 81 | ocean: { |
| 82 | 82 | 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)", | |
| 87 | 87 | }, |
| 88 | 88 | 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)", | |
| 93 | 93 | }, |
| 94 | 94 | }, |
| 95 | 95 | ember: { |
| 96 | 96 | 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)", | |
| 101 | 101 | }, |
| 102 | 102 | 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)", | |
| 107 | 107 | }, |
| 108 | 108 | }, |
| 109 | 109 | iris: { |
| 110 | 110 | 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)", | |
| 115 | 115 | }, |
| 116 | 116 | 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)", | |
| 121 | 121 | }, |
| 122 | 122 | }, |
| 123 | 123 | }; |
| ... | ... | @@ -152,9 +152,26 @@ |
| 152 | 152 | } |
| 153 | 153 | }; |
| 154 | 154 | |
| 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 | }; | |
| 158 | 175 | const isRecord = (value) => |
| 159 | 176 | typeof value === "object" && value !== null && !Array.isArray(value); |
| 160 | 177 | const isThemeId = (value) => |
| ... | ... | @@ -322,13 +339,13 @@ |
| 322 | 339 | const fallbackSplash = isDark ? SPLASH_COLORS.dark : SPLASH_COLORS.light; |
| 323 | 340 | const customSplash = customColors |
| 324 | 341 | ? { |
| 325 | background: isHexColor(customColors.canvas) | |
| 342 | background: isThemeColor(customColors.canvas) | |
| 326 | 343 | ? customColors.canvas |
| 327 | 344 | : (customDefaults?.background ?? fallbackSplash.background), |
| 328 | foreground: isHexColor(customColors.text) | |
| 345 | foreground: isThemeColor(customColors.text) | |
| 329 | 346 | ? customColors.text |
| 330 | 347 | : (customDefaults?.foreground ?? fallbackSplash.foreground), |
| 331 | accent: isHexColor(customColors.accent) | |
| 348 | accent: isThemeColor(customColors.accent) | |
| 332 | 349 | ? customColors.accent |
| 333 | 350 | : (customDefaults?.accent ?? fallbackSplash.accent), |
| 334 | 351 | } |
| ... | ... | @@ -350,7 +367,7 @@ |
| 350 | 367 | } |
| 351 | 368 | document.documentElement.classList.toggle("dark", isDark); |
| 352 | 369 | const chromeColor = customColors |
| 353 | ? isHexColor(customColors.chrome) | |
| 370 | ? isThemeColor(customColors.chrome) | |
| 354 | 371 | ? customColors.chrome |
| 355 | 372 | : (customDefaults?.chrome ?? fallbackSplash.background) |
| 356 | 373 | : builtInSplash |
apps/web/package.json+2| ... | ... | @@ -31,6 +31,7 @@ |
| 31 | 31 | "@tanstack/react-pacer": "^0.19.4", |
| 32 | 32 | "@tanstack/react-router": "^1.160.2", |
| 33 | 33 | "class-variance-authority": "^0.7.1", |
| 34 | "culori": "^4.0.2", | |
| 34 | 35 | "effect": "catalog:", |
| 35 | 36 | "jose": "catalog:", |
| 36 | 37 | "lexical": "^0.41.0", |
| ... | ... | @@ -53,6 +54,7 @@ |
| 53 | 54 | "@tanstack/router-plugin": "^1.161.0", |
| 54 | 55 | "@types/babel__core": "^7.20.5", |
| 55 | 56 | "@types/compression": "^1.8.1", |
| 57 | "@types/culori": "^4.0.1", | |
| 56 | 58 | "@types/react": "~19.2.14", |
| 57 | 59 | "@types/react-dom": "~19.2.3", |
| 58 | 60 | "@vercel/config": "^0.3.0", |
apps/web/src/components/settings/ThemeColorPicker.tsx+11-29| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import type { KeyboardEvent, PointerEvent } from "react"; |
| 2 | 2 | import { memo, useCallback, useEffect, useRef, useState } from "react"; |
| 3 | import { isThemeColor, type ThemeColorRole } from "../../themePalette"; | |
| 3 | import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette"; | |
| 4 | 4 | import { cn } from "../../lib/utils"; |
| 5 | 5 | import { Input } from "../ui/input"; |
| 6 | 6 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| ... | ... | @@ -37,39 +37,18 @@ function clampThemeColor(value: number, min = 0, max = 1) { |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | /** |
| 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. | |
| 43 | 43 | */ |
| 44 | 44 | function themePickerAlphaSuffix(value: string): string { |
| 45 | const trimmed = value.trim().toLowerCase(); | |
| 46 | const alpha = /^#[0-9a-f]{4}$/.test(trimmed) | |
| 47 | ? trimmed.slice(4).repeat(2) | |
| 48 | : /^#[0-9a-f]{8}$/.test(trimmed) | |
| 49 | ? trimmed.slice(7) | |
| 50 | : ""; | |
| 45 | const normalized = themeColorToHex(value) ?? ""; | |
| 46 | const alpha = normalized.length === 9 ? normalized.slice(7) : ""; | |
| 51 | 47 | return alpha === "ff" ? "" : alpha; |
| 52 | 48 | } |
| 53 | 49 | |
| 54 | 50 | function normalizeThemePickerColor(value: string): string { |
| 55 | const trimmed = value.trim(); | |
| 56 | if (/^#[0-9a-f]{3}$/i.test(trimmed)) { | |
| 57 | return `#${trimmed | |
| 58 | .slice(1) | |
| 59 | .split("") | |
| 60 | .map((character) => `${character}${character}`) | |
| 61 | .join("")}`; | |
| 62 | } | |
| 63 | if (/^#[0-9a-f]{4}$/i.test(trimmed)) { | |
| 64 | return `#${trimmed | |
| 65 | .slice(1, 4) | |
| 66 | .split("") | |
| 67 | .map((character) => `${character}${character}`) | |
| 68 | .join("")}`; | |
| 69 | } | |
| 70 | if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed; | |
| 71 | if (/^#[0-9a-f]{8}$/i.test(trimmed)) return trimmed.slice(0, 7); | |
| 72 | return "#000000"; | |
| 51 | return (themeColorToHex(value) ?? "#000000").slice(0, 7); | |
| 73 | 52 | } |
| 74 | 53 | |
| 75 | 54 | function themeHexToHsv(hex: string): ThemeColorHsv { |
| ... | ... | @@ -505,6 +484,9 @@ export const ThemeColorField = memo(function ThemeColorField({ |
| 505 | 484 | const label = customLabel ?? getThemeRoleLabel(role); |
| 506 | 485 | const isColorValue = isThemeColor(value); |
| 507 | 486 | const swatchValue = isColorValue ? value : "#000000"; |
| 487 | const editorValue = value.trim().toLowerCase().startsWith("oklch(") | |
| 488 | ? (themeColorToHex(value) ?? value) | |
| 489 | : value; | |
| 508 | 490 | |
| 509 | 491 | return ( |
| 510 | 492 | <div |
| ... | ... | @@ -542,7 +524,7 @@ export const ThemeColorField = memo(function ThemeColorField({ |
| 542 | 524 | onPointerDown={() => onSelect?.(role)} |
| 543 | 525 | size="sm" |
| 544 | 526 | unstyled |
| 545 | value={value} | |
| 527 | value={editorValue} | |
| 546 | 528 | /> |
| 547 | 529 | </div> |
| 548 | 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 | 268 | :root { |
| 269 | 269 | /* Dev artwork defaults. Built-in themes override these seven pigments in |
| 270 | 270 | 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 | ); | |
| 281 | 293 | --stage-night-highlight: var(--stage-art-highlight); |
| 282 | 294 | --stage-night-secondary: color-mix( |
| 283 | 295 | in oklch, |
| ... | ... | @@ -289,13 +301,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 289 | 301 | var(--stage-art-tertiary) 60%, |
| 290 | 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 | } |
| 294 | 306 | |
| 295 | 307 | .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); | |
| 299 | 311 | } |
| 300 | 312 | |
| 301 | 313 | * { |
| ... | ... | @@ -373,131 +385,131 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 373 | 385 | These colors stay deliberately deep at the top edge so the white stage |
| 374 | 386 | wordmark and controls retain contrast in both appearance modes. */ |
| 375 | 387 | 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); | |
| 383 | 395 | } |
| 384 | 396 | |
| 385 | 397 | 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); | |
| 393 | 405 | } |
| 394 | 406 | |
| 395 | 407 | 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); | |
| 410 | 422 | } |
| 411 | 423 | |
| 412 | 424 | 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); | |
| 427 | 439 | } |
| 428 | 440 | |
| 429 | 441 | 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); | |
| 437 | 449 | } |
| 438 | 450 | |
| 439 | 451 | 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); | |
| 447 | 459 | } |
| 448 | 460 | |
| 449 | 461 | 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); | |
| 464 | 476 | } |
| 465 | 477 | |
| 466 | 478 | 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); | |
| 481 | 493 | } |
| 482 | 494 | |
| 483 | 495 | 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); | |
| 491 | 503 | } |
| 492 | 504 | |
| 493 | 505 | 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); | |
| 501 | 513 | } |
| 502 | 514 | |
| 503 | 515 | .workspace-topbar { |
apps/web/src/themeBoot.test.ts+66-3| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | OCEAN_THEME, |
| 16 | 16 | THEME_APPEARANCE_MODE_STORAGE_KEY, |
| 17 | 17 | THEME_FOLLOW_SYSTEM_STORAGE_KEY, |
| 18 | toCanonicalThemeColor, | |
| 18 | 19 | } from "./themePalette"; |
| 19 | 20 | |
| 20 | 21 | const THEME_STORAGE_KEY = "t3code:theme"; |
| ... | ... | @@ -83,7 +84,12 @@ function runBootScript(options: { |
| 83 | 84 | matchMedia: () => ({ matches: options.prefersDark }), |
| 84 | 85 | }; |
| 85 | 86 | |
| 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); | |
| 87 | 93 | |
| 88 | 94 | return { |
| 89 | 95 | isDark: classes.has("dark"), |
| ... | ... | @@ -275,6 +281,63 @@ describe("index.html boot script", () => { |
| 275 | 281 | expect(aurora.metaContent).toBe(DEFAULT_DARK_CHROME); |
| 276 | 282 | }); |
| 277 | 283 | |
| 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 | 341 | // Asserting against the real palette definitions (not literals) turns the |
| 279 | 342 | // boot script's hand-maintained copy into a CI-enforced contract: any |
| 280 | 343 | // palette change breaks this test until the copy in index.html is updated. |
| ... | ... | @@ -410,9 +473,9 @@ describe("index.html boot script", () => { |
| 410 | 473 | }); |
| 411 | 474 | |
| 412 | 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 | 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 | 479 | expect(boot.backgroundColor).toBe(DEFAULT_DARK_CHROME); |
| 417 | 480 | expect(boot.metaContent).toBe(DEFAULT_DARK_CHROME); |
| 418 | 481 | }); |
apps/web/src/themePalette.test.ts+285-48| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | canonicalThemePreference, |
| 16 | 16 | parseThemeFile, |
| 17 | 17 | parseThemeHalves, |
| 18 | removeCustomTheme, | |
| 18 | 19 | resolveDesktopTheme, |
| 19 | 20 | resolveThemeAppearance, |
| 20 | 21 | serializeThemeFile, |
| ... | ... | @@ -31,12 +32,35 @@ import { |
| 31 | 32 | createManagedThemeColors, |
| 32 | 33 | createVividThemeColors, |
| 33 | 34 | getDefaultThemeColors, |
| 35 | themeColorToHex, | |
| 36 | toCanonicalThemeColor, | |
| 34 | 37 | THEME_FILE_VERSION, |
| 35 | 38 | } from "./themePalette"; |
| 36 | 39 | |
| 40 | function 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 | ||
| 46 | function 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 | ||
| 52 | function 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 | ||
| 37 | 61 | function contrastRatio(first: string, second: string): number { |
| 38 | 62 | const toRgb = (value: string) => { |
| 39 | const hex = value.slice(1); | |
| 63 | const hex = asHex(value).slice(1); | |
| 40 | 64 | return [0, 1, 2].map( |
| 41 | 65 | (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255, |
| 42 | 66 | ); |
| ... | ... | @@ -56,8 +80,8 @@ describe("theme files", () => { |
| 56 | 80 | const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00"); |
| 57 | 81 | const darkDefaults = getDefaultThemeColors("dark"); |
| 58 | 82 | |
| 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"); | |
| 61 | 85 | expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5); |
| 62 | 86 | expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5); |
| 63 | 87 | expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5); |
| ... | ... | @@ -73,7 +97,7 @@ describe("theme files", () => { |
| 73 | 97 | // Status colors fall back to T3 Code's standard red and amber rather than |
| 74 | 98 | // the flagship palette's, so no generated theme inherits a brand tint. |
| 75 | 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 | 101 | number, |
| 78 | 102 | number, |
| 79 | 103 | number, |
| ... | ... | @@ -92,7 +116,7 @@ describe("theme files", () => { |
| 92 | 116 | expect(warnRed).toBeGreaterThan(warnBlue); |
| 93 | 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 | }); |
| 97 | 121 | |
| 98 | 122 | it("derives readable, distinctive vivid palettes from exact seeds", () => { |
| ... | ... | @@ -108,8 +132,10 @@ describe("theme files", () => { |
| 108 | 132 | for (const [appearance, canvas, accent] of seeds) { |
| 109 | 133 | const colors = createVividThemeColors(appearance, canvas, accent); |
| 110 | 134 | // 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); | |
| 113 | 139 | // Readability is solved per surface. |
| 114 | 140 | expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7); |
| 115 | 141 | expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5); |
| ... | ... | @@ -131,7 +157,7 @@ describe("theme files", () => { |
| 131 | 157 | // The companion action is a distinct voice, not the accent again. |
| 132 | 158 | expect(colors.messageAction).not.toBe(colors.accent); |
| 133 | 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 | }); |
| 137 | 163 | |
| ... | ... | @@ -170,13 +196,53 @@ describe("theme files", () => { |
| 170 | 196 | label: "Ocean dusk", |
| 171 | 197 | appearance: "dark", |
| 172 | 198 | colors: { |
| 173 | canvas: "#07152f", | |
| 174 | accent: "#67c2ff", | |
| 175 | placeholder: "#968d9f", | |
| 199 | canvas: canonical("#07152f"), | |
| 200 | accent: canonical("#67c2ff"), | |
| 201 | placeholder: canonical("#968d9f"), | |
| 176 | 202 | }, |
| 177 | 203 | }); |
| 178 | 204 | }); |
| 179 | 205 | |
| 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 | 246 | it("rejects unknown roles and invalid color values", () => { |
| 181 | 247 | expect(() => |
| 182 | 248 | parseThemeFile({ |
| ... | ... | @@ -194,16 +260,20 @@ describe("theme files", () => { |
| 194 | 260 | appearance: "light", |
| 195 | 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 | }); |
| 199 | 265 | |
| 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 | }); | |
| 202 | 271 | expect(JSON.parse(serialized)).toMatchObject({ |
| 203 | 272 | version: THEME_FILE_VERSION, |
| 204 | 273 | id: T3_CHAT_THEME.id, |
| 205 | 274 | name: T3_CHAT_THEME.label, |
| 206 | 275 | appearance: "light", |
| 276 | colors: { accent: canonical("hsl(263 70% 58%)") }, | |
| 207 | 277 | }); |
| 208 | 278 | }); |
| 209 | 279 | |
| ... | ... | @@ -257,8 +327,8 @@ describe("theme files", () => { |
| 257 | 327 | |
| 258 | 328 | expect(getThemeModes(theme)).toEqual(["light", "dark"]); |
| 259 | 329 | expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ |
| 260 | canvas: "#101827", | |
| 261 | text: "#eef5ff", | |
| 330 | canvas: canonical("#101827"), | |
| 331 | text: canonical("#eef5ff"), | |
| 262 | 332 | }); |
| 263 | 333 | expect(getThemeModes(T3_CHAT_THEME)).toEqual(["light", "dark"]); |
| 264 | 334 | expect(resolveThemeAppearance(T3_CHAT_THEME.id, true, true)).toBe("dark"); |
| ... | ... | @@ -266,13 +336,13 @@ describe("theme files", () => { |
| 266 | 336 | expect(resolveThemeAppearance(T3_CHAT_THEME.id, false, false, "dark")).toBe("dark"); |
| 267 | 337 | expect(resolveDesktopTheme(T3_CHAT_THEME.id, false, "dark")).toBe("dark"); |
| 268 | 338 | expect(JSON.parse(serializeThemeFile(theme)).variants.dark).toMatchObject({ |
| 269 | canvas: "#101827", | |
| 270 | text: "#eef5ff", | |
| 339 | canvas: canonical("#101827"), | |
| 340 | text: canonical("#eef5ff"), | |
| 271 | 341 | }); |
| 272 | 342 | }); |
| 273 | 343 | |
| 274 | 344 | it("keeps the T3 Chat palette faithful and readable", () => { |
| 275 | expect(T3_CHAT_THEME.colors).toMatchObject({ | |
| 345 | expectThemeColors(T3_CHAT_THEME.colors, { | |
| 276 | 346 | canvas: "#fdf7fd", |
| 277 | 347 | chrome: "#fdf7fd", |
| 278 | 348 | toolbarBorder: "#efbdeb", |
| ... | ... | @@ -287,7 +357,7 @@ describe("theme files", () => { |
| 287 | 357 | accentSurface: "#f3e6f5", |
| 288 | 358 | sidebar: "#f2e1f4", |
| 289 | 359 | }); |
| 290 | expect(T3_CHAT_THEME.variants?.dark).toMatchObject({ | |
| 360 | expectThemeColors(T3_CHAT_THEME.variants!.dark!, { | |
| 291 | 361 | canvas: "#1f1a24", |
| 292 | 362 | chrome: "#1f1a24", |
| 293 | 363 | surface: "#29232d", |
| ... | ... | @@ -321,8 +391,8 @@ describe("theme files", () => { |
| 321 | 391 | expect(getThemeModes(theme)).toEqual(["light", "dark"]); |
| 322 | 392 | expect(theme.sidebarArtwork).toBe(true); |
| 323 | 393 | 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\(/); | |
| 326 | 396 | |
| 327 | 397 | for (const mode of ["light", "dark"] as const) { |
| 328 | 398 | const colors = getThemeColorsForMode(theme, mode); |
| ... | ... | @@ -380,7 +450,9 @@ describe("theme files", () => { |
| 380 | 450 | }); |
| 381 | 451 | |
| 382 | 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 | 456 | expect(getThemeColorsForMode(theme, "light")).toBeNull(); |
| 385 | 457 | }); |
| 386 | 458 | |
| ... | ... | @@ -419,8 +491,16 @@ describe("theme files", () => { |
| 419 | 491 | vi.unstubAllGlobals(); |
| 420 | 492 | }); |
| 421 | 493 | |
| 422 | it("updates a personal theme without changing its id", () => { | |
| 494 | it("canonicalizes explicit writes without migrating untouched themes", () => { | |
| 423 | 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 | 504 | vi.stubGlobal("window", { |
| 425 | 505 | localStorage: { |
| 426 | 506 | getItem: (key: string) => stored.get(key) ?? null, |
| ... | ... | @@ -442,23 +522,175 @@ describe("theme files", () => { |
| 442 | 522 | const updatedTheme = updateCustomTheme({ |
| 443 | 523 | ...createdTheme, |
| 444 | 524 | label: "Aurora Night", |
| 445 | colors: { ...createdTheme.colors, accent: "#7c3aed" }, | |
| 525 | colors: { ...createdTheme.colors, accent: "hsl(263 70% 58%)" }, | |
| 446 | 526 | }); |
| 447 | 527 | |
| 448 | 528 | expect(updatedTheme).toMatchObject({ |
| 449 | 529 | id: "aurora", |
| 450 | 530 | label: "Aurora Night", |
| 531 | colors: { accent: canonical("hsl(263 70% 58%)") }, | |
| 451 | 532 | }); |
| 452 | 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 | 542 | 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({ | |
| 456 | 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 | 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( | |
| 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, | |
| 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); | |
| 462 | 694 | |
| 463 | 695 | vi.unstubAllGlobals(); |
| 464 | 696 | invalidateCustomThemes(); |
| ... | ... | @@ -530,23 +762,23 @@ describe("stored theme preferences", () => { |
| 530 | 762 | expect(isKnownThemePreference("missing-theme")).toBe(false); |
| 531 | 763 | }); |
| 532 | 764 | |
| 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 | 778 | vi.stubGlobal("window", { |
| 535 | 779 | 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, | |
| 550 | 782 | }, |
| 551 | 783 | }); |
| 552 | 784 | invalidateCustomThemes(); |
| ... | ... | @@ -555,11 +787,16 @@ describe("stored theme preferences", () => { |
| 555 | 787 | expect(themes).toHaveLength(1); |
| 556 | 788 | expect(themes[0]).toMatchObject({ |
| 557 | 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"]); | |
| 563 | 800 | |
| 564 | 801 | vi.unstubAllGlobals(); |
| 565 | 802 | invalidateCustomThemes(); |
apps/web/src/themePalette.ts+379-213| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | import "culori/css"; | |
| 3 | import { converter, parse } from "culori/fn"; | |
| 2 | 4 | |
| 3 | 5 | export const T3_CHAT_THEME_ID = "t3-chat" as const; |
| 4 | 6 | export const T3_CHAT_THEME_LABEL = "T3 Chat"; |
| ... | ... | @@ -129,7 +131,16 @@ const RESERVED_THEME_IDS = new Set([ |
| 129 | 131 | ]); |
| 130 | 132 | |
| 131 | 133 | const customThemeListeners = new Set<() => void>(); |
| 132 | let customThemesSnapshot: ReadonlyArray<ThemeDefinition> | null = null; | |
| 134 | type 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 | ||
| 143 | let customThemeLibrarySnapshot: CustomThemeLibrarySnapshot | null = null; | |
| 133 | 144 | const themePreviewListeners = new Set<() => void>(); |
| 134 | 145 | let themePreviewSidebarArtwork: boolean | null = null; |
| 135 | 146 | |
| ... | ... | @@ -157,10 +168,7 @@ function isThemeAppearance(value: unknown): value is ThemeAppearance { |
| 157 | 168 | } |
| 158 | 169 | |
| 159 | 170 | export 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; | |
| 164 | 172 | } |
| 165 | 173 | |
| 166 | 174 | function isThemeId(value: unknown): value is string { |
| ... | ... | @@ -180,8 +188,9 @@ function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): Th |
| 180 | 188 | // Tolerate unknown roles and malformed values so themes saved by other |
| 181 | 189 | // builds (for example one that adds a new role) keep their remaining colors. |
| 182 | 190 | 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; | |
| 185 | 194 | } |
| 186 | 195 | } |
| 187 | 196 | return colors as ThemeColors; |
| ... | ... | @@ -226,26 +235,43 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { |
| 226 | 235 | }; |
| 227 | 236 | } |
| 228 | 237 | |
| 229 | function readCustomThemesFromStorage(): ReadonlyArray<ThemeDefinition> { | |
| 230 | if (typeof window === "undefined") return []; | |
| 238 | function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { | |
| 239 | if (typeof window === "undefined") { | |
| 240 | return { status: "ready", storedThemes: [], themes: [] }; | |
| 241 | } | |
| 231 | 242 | |
| 243 | let raw: string | null; | |
| 232 | 244 | 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); | |
| 246 | 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 | ||
| 270 | function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { | |
| 271 | if (customThemeLibrarySnapshot === null) { | |
| 272 | customThemeLibrarySnapshot = readCustomThemeLibrarySnapshot(); | |
| 273 | } | |
| 274 | return customThemeLibrarySnapshot; | |
| 249 | 275 | } |
| 250 | 276 | |
| 251 | 277 | function notifyCustomThemeListeners() { |
| ... | ... | @@ -253,15 +279,13 @@ function notifyCustomThemeListeners() { |
| 253 | 279 | } |
| 254 | 280 | |
| 255 | 281 | export function invalidateCustomThemes() { |
| 256 | customThemesSnapshot = null; | |
| 282 | customThemeLibrarySnapshot = null; | |
| 257 | 283 | notifyCustomThemeListeners(); |
| 258 | 284 | } |
| 259 | 285 | |
| 260 | 286 | export 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 : []; | |
| 265 | 289 | } |
| 266 | 290 | |
| 267 | 291 | export function subscribeToCustomThemes(listener: () => void): () => void { |
| ... | ... | @@ -470,7 +494,7 @@ const T3_CHAT_DARK_COLORS: ThemeColors = { |
| 470 | 494 | * stock tokens (index.css) so a draft seeded from the default look paints the |
| 471 | 495 | * pixels the user is already seeing. Alpha-bearing tokens are flattened over |
| 472 | 496 | * 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 | */ |
| 475 | 499 | const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = { |
| 476 | 500 | canvas: "#fcfcfc", |
| ... | ... | @@ -599,7 +623,10 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = { |
| 599 | 623 | * files. |
| 600 | 624 | */ |
| 601 | 625 | export 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 | } |
| 604 | 631 | |
| 605 | 632 | type ThemeRgbColor = { |
| ... | ... | @@ -614,34 +641,81 @@ type ThemeHslColor = { |
| 614 | 641 | l: number; |
| 615 | 642 | }; |
| 616 | 643 | |
| 644 | type ThemeOklch = { L: number; C: number; h: number }; | |
| 645 | type ParsedThemeColor = { color: ThemeOklch; alpha: number }; | |
| 646 | ||
| 647 | let standardLightThemeColors: ThemeColors | undefined; | |
| 648 | let standardDarkThemeColors: ThemeColors | undefined; | |
| 649 | ||
| 617 | 650 | const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 }; |
| 618 | 651 | const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 }; |
| 619 | 652 | const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 }; |
| 620 | 653 | const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 }; |
| 621 | 654 | |
| 622 | function 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 | ||
| 655 | const convertToOklch = converter("oklch"); | |
| 656 | ||
| 657 | function 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; | |
| 638 | 670 | 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)), | |
| 642 | 677 | }; |
| 643 | 678 | } |
| 644 | 679 | |
| 680 | function 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 | ||
| 685 | function 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 | */ | |
| 695 | export 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. */ | |
| 701 | export 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 | ||
| 714 | function parseThemeRgbColor(value: string, fallback: ThemeRgbColor): ThemeRgbColor { | |
| 715 | const parsed = parseThemeColor(value); | |
| 716 | return parsed ? themeOklchToRgb(parsed.color) : fallback; | |
| 717 | } | |
| 718 | ||
| 645 | 719 | function themeRgbToHexColor(color: ThemeRgbColor): string { |
| 646 | 720 | return `#${[color.r, color.g, color.b] |
| 647 | 721 | .map((channel) => |
| ... | ... | @@ -652,6 +726,41 @@ function themeRgbToHexColor(color: ThemeRgbColor): string { |
| 652 | 726 | .join("")}`; |
| 653 | 727 | } |
| 654 | 728 | |
| 729 | function themeRgbToThemeColor(color: ThemeRgbColor): string { | |
| 730 | return formatOklchThemeColor(themeRgbToOklch(color)); | |
| 731 | } | |
| 732 | ||
| 733 | function 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 | ||
| 747 | function 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 | ||
| 655 | 764 | function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor { |
| 656 | 765 | const red = color.r / 255; |
| 657 | 766 | const green = color.g / 255; |
| ... | ... | @@ -717,10 +826,6 @@ function themeRelativeLuminance(color: ThemeRgbColor): number { |
| 717 | 826 | |
| 718 | 827 | // --------------------------------------------------------------------------- |
| 719 | 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 | ||
| 723 | type ThemeOklch = { L: number; C: number; h: number }; | |
| 724 | 829 | |
| 725 | 830 | function srgbChannelToLinear(channel: number): number { |
| 726 | 831 | const c = channel / 255; |
| ... | ... | @@ -759,24 +864,30 @@ function oklchToRgbUnclamped({ L, C, h }: ThemeOklch): { r: number; g: number; b |
| 759 | 864 | }; |
| 760 | 865 | } |
| 761 | 866 | |
| 762 | /** Convert to sRGB, walking chroma toward grey until the color is in gamut. */ | |
| 763 | function 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. */ | |
| 868 | function mapThemeOklchToSrgbGamut(color: ThemeOklch): ThemeOklch { | |
| 869 | const isInGamut = (C: number) => { | |
| 766 | 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 | 872 | (channel) => channel >= -0.0001 && channel <= 1.0001, |
| 769 | 873 | ); |
| 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; | |
| 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. */ | |
| 889 | function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor { | |
| 890 | const linear = oklchToRgbUnclamped(mapThemeOklchToSrgbGamut(color)); | |
| 780 | 891 | return { |
| 781 | 892 | r: linearChannelToSrgb(linear.r), |
| 782 | 893 | g: linearChannelToSrgb(linear.g), |
| ... | ... | @@ -784,6 +895,10 @@ function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor { |
| 784 | 895 | }; |
| 785 | 896 | } |
| 786 | 897 | |
| 898 | function themeOklchToThemeColor(color: ThemeOklch): string { | |
| 899 | return formatOklchThemeColor(mapThemeOklchToSrgbGamut(color)); | |
| 900 | } | |
| 901 | ||
| 787 | 902 | /** Binary-search the lightness that reaches the contrast target against a background. */ |
| 788 | 903 | function solveOklchLightness( |
| 789 | 904 | base: ThemeOklch, |
| ... | ... | @@ -856,26 +971,24 @@ function standardStatusColors(canvas: ThemeRgbColor): { |
| 856 | 971 | // tinted one they can fall just short, so lightness is nudged until the |
| 857 | 972 | // pair clears 4.5 while the hue stays standard. |
| 858 | 973 | 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", | |
| 869 | 981 | ), |
| 870 | 982 | ); |
| 871 | 983 | const errorSurface = surfaceOf(standard.error); |
| 872 | 984 | const warningSurface = surfaceOf(standard.warning); |
| 873 | 985 | return { |
| 874 | ...standard, | |
| 986 | error: toCanonicalThemeColor(standard.error)!, | |
| 875 | 987 | errorForeground: readableOn(standard.errorForeground, errorSurface), |
| 876 | errorSurface: themeRgbToHexColor(errorSurface), | |
| 988 | errorSurface: themeRgbToThemeColor(errorSurface), | |
| 989 | warning: toCanonicalThemeColor(standard.warning)!, | |
| 877 | 990 | warningForeground: readableOn(standard.warningForeground, warningSurface), |
| 878 | warningSurface: themeRgbToHexColor(warningSurface), | |
| 991 | warningSurface: themeRgbToThemeColor(warningSurface), | |
| 879 | 992 | }; |
| 880 | 993 | } |
| 881 | 994 | |
| ... | ... | @@ -912,7 +1025,7 @@ export function createVividThemeColors( |
| 912 | 1025 | C: chroma, |
| 913 | 1026 | h: hue, |
| 914 | 1027 | }); |
| 915 | const hex = (color: ThemeOklch) => themeRgbToHexColor(themeOklchToRgb(color)); | |
| 1028 | const themeColor = (color: ThemeOklch) => themeOklchToThemeColor(color); | |
| 916 | 1029 | |
| 917 | 1030 | // Text carries a whisper of the accent hue instead of falling back to a |
| 918 | 1031 | // fixed foreground, and is solved to WCAG AAA against the canvas. |
| ... | ... | @@ -956,8 +1069,8 @@ export function createVividThemeColors( |
| 956 | 1069 | const updateSurface = surfaceAt(dark ? 0.14 : 0.09, Math.min(0.12, accent.C * 0.55)); |
| 957 | 1070 | |
| 958 | 1071 | 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"), | |
| 961 | 1074 | ); |
| 962 | 1075 | const mutedForeground = foregroundOn(mutedRgb); |
| 963 | 1076 | const placeholder = foregroundOn(surfaceRaisedRgb); |
| ... | ... | @@ -967,58 +1080,58 @@ export function createVividThemeColors( |
| 967 | 1080 | return { |
| 968 | 1081 | ...defaults, |
| 969 | 1082 | ...standardStatusColors(canvasRgb), |
| 970 | canvas: themeRgbToHexColor(canvasRgb), | |
| 1083 | canvas: themeRgbToThemeColor(canvasRgb), | |
| 971 | 1084 | // 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), | |
| 990 | 1103 | secondaryForeground: foregroundOn(secondaryRgb), |
| 991 | muted: hex(muted), | |
| 1104 | muted: themeColor(muted), | |
| 992 | 1105 | mutedForeground, |
| 993 | 1106 | 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), | |
| 997 | 1110 | updateForeground: foregroundOn(themeOklchToRgb(updateSurface)), |
| 998 | updateSurface: hex(updateSurface), | |
| 999 | accentSurface: hex(accentSurface), | |
| 1111 | updateSurface: themeColor(updateSurface), | |
| 1112 | accentSurface: themeColor(accentSurface), | |
| 1000 | 1113 | accentSurfaceForeground: foregroundOn(accentSurfaceRgb), |
| 1001 | messageSurface: hex(messageSurface), | |
| 1114 | messageSurface: themeColor(messageSurface), | |
| 1002 | 1115 | 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), | |
| 1009 | 1122 | 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)), | |
| 1022 | 1135 | }; |
| 1023 | 1136 | } |
| 1024 | 1137 | |
| ... | ... | @@ -1127,8 +1240,7 @@ function managedThemeAccent( |
| 1127 | 1240 | const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness }); |
| 1128 | 1241 | return { color, lightness, contrast: themeContrastRatio(color, background) }; |
| 1129 | 1242 | }); |
| 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. | |
| 1132 | 1244 | const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7); |
| 1133 | 1245 | const pool = readableCandidates.length > 0 ? readableCandidates : candidates; |
| 1134 | 1246 | |
| ... | ... | @@ -1210,80 +1322,80 @@ export function createManagedThemeColors( |
| 1210 | 1322 | return { |
| 1211 | 1323 | ...defaults, |
| 1212 | 1324 | ...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), | |
| 1229 | 1341 | // Borders blend through the accent before lightening so control chrome |
| 1230 | 1342 | // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1) |
| 1231 | 1343 | // instead of flattening to grey. |
| 1232 | border: themeRgbToHexColor( | |
| 1344 | border: themeRgbToThemeColor( | |
| 1233 | 1345 | mixThemeRgbColors( |
| 1234 | 1346 | mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1), |
| 1235 | 1347 | text, |
| 1236 | 1348 | 0.1, |
| 1237 | 1349 | ), |
| 1238 | 1350 | ), |
| 1239 | input: themeRgbToHexColor( | |
| 1351 | input: themeRgbToThemeColor( | |
| 1240 | 1352 | mixThemeRgbColors( |
| 1241 | 1353 | mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14), |
| 1242 | 1354 | text, |
| 1243 | 1355 | appearance === "dark" ? 0.14 : 0.13, |
| 1244 | 1356 | ), |
| 1245 | 1357 | ), |
| 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( | |
| 1269 | 1381 | mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08), |
| 1270 | 1382 | ), |
| 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( | |
| 1275 | 1387 | mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12), |
| 1276 | 1388 | ), |
| 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( | |
| 1281 | 1393 | mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18), |
| 1282 | 1394 | ), |
| 1283 | terminalScrollbar: themeRgbToHexColor( | |
| 1395 | terminalScrollbar: themeRgbToThemeColor( | |
| 1284 | 1396 | mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22), |
| 1285 | 1397 | ), |
| 1286 | terminalScrollbarHover: themeRgbToHexColor( | |
| 1398 | terminalScrollbarHover: themeRgbToThemeColor( | |
| 1287 | 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 | 1405 | id: T3_CHAT_THEME_ID, |
| 1294 | 1406 | label: T3_CHAT_THEME_LABEL, |
| 1295 | 1407 | appearance: "light", |
| 1296 | colors: T3_CHAT_LIGHT_COLORS, | |
| 1408 | colors: decodeThemeColors(T3_CHAT_LIGHT_COLORS), | |
| 1297 | 1409 | variants: { |
| 1298 | dark: T3_CHAT_DARK_COLORS, | |
| 1410 | dark: decodeThemeColors(T3_CHAT_DARK_COLORS), | |
| 1299 | 1411 | }, |
| 1300 | 1412 | sidebarArtwork: true, |
| 1301 | 1413 | }; |
| 1302 | 1414 | |
| 1303 | 1415 | /** Theme-file defaults follow the flagship palette for the requested mode. */ |
| 1304 | 1416 | export 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 | } |
| 1307 | 1419 | |
| 1308 | 1420 | /** |
| ... | ... | @@ -1320,9 +1432,9 @@ function themeActionColors( |
| 1320 | 1432 | ? THEME_BLACK_FOREGROUND |
| 1321 | 1433 | : THEME_WHITE_FOREGROUND; |
| 1322 | 1434 | 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)), | |
| 1326 | 1438 | }; |
| 1327 | 1439 | } |
| 1328 | 1440 | |
| ... | ... | @@ -1452,39 +1564,72 @@ export function themeIdFromName(name: string): string { |
| 1452 | 1564 | |
| 1453 | 1565 | export class ThemeLibraryStorageError extends Schema.TaggedErrorClass<ThemeLibraryStorageError>()( |
| 1454 | 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 | 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 | } |
| 1461 | 1579 | |
| 1462 | 1580 | export const isThemeLibraryStorageError = Schema.is(ThemeLibraryStorageError); |
| 1463 | 1581 | |
| 1464 | function saveCustomThemes(themes: ReadonlyArray<ThemeDefinition>): void { | |
| 1582 | function saveCustomThemes( | |
| 1583 | storedThemes: ReadonlyArray<unknown>, | |
| 1584 | themes: ReadonlyArray<ThemeDefinition>, | |
| 1585 | ): void { | |
| 1465 | 1586 | if (typeof window === "undefined") return; |
| 1466 | 1587 | 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 }; | |
| 1469 | 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 | 1598 | notifyCustomThemeListeners(); |
| 1473 | 1599 | } |
| 1474 | 1600 | |
| 1601 | function 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 | ||
| 1614 | function storedThemeHasId(storedTheme: unknown, themeId: string): boolean { | |
| 1615 | return isRecord(storedTheme) && storedTheme.id === themeId; | |
| 1616 | } | |
| 1617 | ||
| 1475 | 1618 | export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition { |
| 1476 | 1619 | if (RESERVED_THEME_IDS.has(theme.id)) { |
| 1477 | 1620 | throw new Error(`The theme id "${theme.id}" is reserved.`); |
| 1478 | 1621 | } |
| 1622 | const library = getWritableCustomThemeLibrary(); | |
| 1479 | 1623 | 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)) | |
| 1483 | 1626 | ) { |
| 1484 | 1627 | throw new Error(`A theme named "${theme.label}" is already installed.`); |
| 1485 | 1628 | } |
| 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; | |
| 1488 | 1633 | } |
| 1489 | 1634 | |
| 1490 | 1635 | export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition { |
| ... | ... | @@ -1492,22 +1637,39 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition { |
| 1492 | 1637 | throw new Error(`The theme id "${theme.id}" is reserved.`); |
| 1493 | 1638 | } |
| 1494 | 1639 | |
| 1495 | const themes = getCustomThemes(); | |
| 1640 | const library = getWritableCustomThemeLibrary(); | |
| 1641 | const themes = library.themes; | |
| 1496 | 1642 | const themeIndex = themes.findIndex((existing) => existing.id === theme.id); |
| 1497 | 1643 | if (themeIndex === -1) { |
| 1498 | 1644 | throw new Error(`The theme "${theme.label}" is not installed.`); |
| 1499 | 1645 | } |
| 1500 | 1646 | |
| 1647 | const canonicalTheme = canonicalizeThemeDefinition(theme); | |
| 1501 | 1648 | 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; | |
| 1505 | 1663 | } |
| 1506 | 1664 | |
| 1507 | 1665 | export 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 | ); | |
| 1511 | 1673 | } |
| 1512 | 1674 | |
| 1513 | 1675 | function parseThemeColorOverrides(value: unknown): ThemeColorOverrides { |
| ... | ... | @@ -1518,10 +1680,13 @@ function parseThemeColorOverrides(value: unknown): ThemeColorOverrides { |
| 1518 | 1680 | if (!THEME_COLOR_ROLE_SET.has(role)) { |
| 1519 | 1681 | throw new Error(`"${role}" is not a supported theme color role.`); |
| 1520 | 1682 | } |
| 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 | ); | |
| 1523 | 1688 | } |
| 1524 | overrides[role as ThemeColorRole] = color; | |
| 1689 | overrides[role as ThemeColorRole] = normalized; | |
| 1525 | 1690 | } |
| 1526 | 1691 | if (Object.keys(overrides).length === 0) { |
| 1527 | 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 | 1751 | } |
| 1587 | 1752 | |
| 1588 | 1753 | export function serializeThemeFile(theme: ThemeDefinition): string { |
| 1754 | const canonicalTheme = canonicalizeThemeDefinition(theme); | |
| 1589 | 1755 | const file: ThemeFile = { |
| 1590 | 1756 | 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 } : {}), | |
| 1597 | 1763 | }; |
| 1598 | 1764 | return `${JSON.stringify(file, null, 2)}\n`; |
| 1599 | 1765 | } |
apps/web/src/vscodeThemeImport.test.ts+26-19| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { getThemeColorsForMode, THEME_FILE_VERSION } from "./themePalette"; | |
| 3 | import { getThemeColorsForMode, themeColorToHex, THEME_FILE_VERSION } from "./themePalette"; | |
| 4 | 4 | import { |
| 5 | 5 | isVsCodeThemeFile, |
| 6 | 6 | pairVsCodeThemes, |
| ... | ... | @@ -8,9 +8,15 @@ import { |
| 8 | 8 | resolveThemeLabelCollisions, |
| 9 | 9 | } from "./vscodeThemeImport"; |
| 10 | 10 | |
| 11 | function 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 | ||
| 11 | 17 | function contrastRatio(first: string, second: string): number { |
| 12 | 18 | const toChannels = (value: string) => { |
| 13 | const hex = value.slice(1); | |
| 19 | const hex = asHex(value).slice(1); | |
| 14 | 20 | return [0, 1, 2].map( |
| 15 | 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 | 75 | // The slug name is read as words; a displayName would win verbatim. |
| 70 | 76 | expect(theme.label).toBe("Pierre Dark Soft"); |
| 71 | 77 | 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"); | |
| 77 | 83 | }); |
| 78 | 84 | |
| 79 | 85 | it("flattens alpha overlays onto the surface they sit on", () => { |
| 80 | 86 | const theme = parseVsCodeThemeFile(VSCODE_DARK); |
| 81 | 87 | // #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"); | |
| 84 | 90 | expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar); |
| 85 | 91 | }); |
| 86 | 92 | |
| ... | ... | @@ -88,7 +94,7 @@ describe("VS Code theme import", () => { |
| 88 | 94 | const theme = parseVsCodeThemeFile(VSCODE_DARK); |
| 89 | 95 | const colors = getThemeColorsForMode(theme, "dark")!; |
| 90 | 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 | 99 | expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(4.5); |
| 94 | 100 | expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5); |
| ... | ... | @@ -117,7 +123,7 @@ describe("VS Code theme import", () => { |
| 117 | 123 | type: "dark", |
| 118 | 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 | 127 | expect(contrastRatio(theme.colors.text, theme.colors.canvas)).toBeGreaterThanOrEqual(4.5); |
| 122 | 128 | }); |
| 123 | 129 | |
| ... | ... | @@ -134,7 +140,7 @@ describe("VS Code theme import", () => { |
| 134 | 140 | "terminal.background": "#fbfbfb", |
| 135 | 141 | }, |
| 136 | 142 | }); |
| 137 | expect(theme.colors.sidebar).toBe("#fafafa"); | |
| 143 | expect(asHex(theme.colors.sidebar)).toBe("#fafafa"); | |
| 138 | 144 | expect( |
| 139 | 145 | contrastRatio(theme.colors.sidebarForeground, theme.colors.sidebar), |
| 140 | 146 | ).toBeGreaterThanOrEqual(4.5); |
| ... | ... | @@ -156,10 +162,10 @@ describe("VS Code theme import", () => { |
| 156 | 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]/); | |
| 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]/); | |
| 161 | 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 | 169 | const [red, green, blue] = [1, 3, 5].map((index) => |
| 164 | 170 | Number.parseInt(accent.slice(index, index + 2), 16), |
| 165 | 171 | ) as [number, number, number]; |
| ... | ... | @@ -192,8 +198,8 @@ describe("VS Code theme import", () => { |
| 192 | 198 | const github = themes[0]!; |
| 193 | 199 | expect(github.appearance).toBe("light"); |
| 194 | 200 | 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"); | |
| 197 | 203 | // The unpaired dimmed variant stays a single dark theme. |
| 198 | 204 | expect(getThemeColorsForMode(themes[2]!, "light")).toBeNull(); |
| 199 | 205 | }); |
| ... | ... | @@ -220,14 +226,15 @@ describe("VS Code theme import", () => { |
| 220 | 226 | // surface, plain surfaces) must stay near the canvas, not turn blue. |
| 221 | 227 | const theme = parseVsCodeThemeFile(VSCODE_DARK); |
| 222 | 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 | 231 | return Math.max(...channels) - Math.min(...channels); |
| 225 | 232 | }; |
| 226 | 233 | expect(spread(theme.colors.codeBackground)).toBeLessThanOrEqual(8); |
| 227 | 234 | expect(spread(theme.colors.surface)).toBeLessThanOrEqual(8); |
| 228 | 235 | expect(spread(theme.colors.text)).toBeLessThanOrEqual(12); |
| 229 | 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 | }); |
| 232 | 239 | |
| 233 | 240 | it("tells same-named variants apart by their file names", () => { |
apps/web/src/vscodeThemeImport.ts+2-1| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | createVividThemeColors, |
| 3 | 3 | getThemeModes, |
| 4 | 4 | parseThemeFile, |
| 5 | themeColorToHex, | |
| 5 | 6 | THEME_FILE_VERSION, |
| 6 | 7 | type ThemeAppearance, |
| 7 | 8 | type ThemeColorRole, |
| ... | ... | @@ -138,7 +139,7 @@ function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number { |
| 138 | 139 | } |
| 139 | 140 | |
| 140 | 141 | function 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 | } |
| 143 | 144 | |
| 144 | 145 | /** |
pnpm-lock.yaml+11| ... | ... | @@ -570,6 +570,9 @@ importers: |
| 570 | 570 | class-variance-authority: |
| 571 | 571 | specifier: ^0.7.1 |
| 572 | 572 | version: 0.7.1 |
| 573 | culori: | |
| 574 | specifier: ^4.0.2 | |
| 575 | version: 4.0.2 | |
| 573 | 576 | effect: |
| 574 | 577 | specifier: 4.0.0-beta.103 |
| 575 | 578 | version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) |
| ... | ... | @@ -631,6 +634,9 @@ importers: |
| 631 | 634 | '@types/compression': |
| 632 | 635 | specifier: ^1.8.1 |
| 633 | 636 | version: 1.8.1 |
| 637 | '@types/culori': | |
| 638 | specifier: ^4.0.1 | |
| 639 | version: 4.0.1 | |
| 634 | 640 | '@types/react': |
| 635 | 641 | specifier: ~19.2.14 |
| 636 | 642 | version: 19.2.16 |
| ... | ... | @@ -4794,6 +4800,9 @@ packages: |
| 4794 | 4800 | '@types/connect@3.4.38': |
| 4795 | 4801 | resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} |
| 4796 | 4802 | |
| 4803 | '@types/culori@4.0.1': | |
| 4804 | resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} | |
| 4805 | ||
| 4797 | 4806 | '@types/debug@4.1.13': |
| 4798 | 4807 | resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} |
| 4799 | 4808 | |
| ... | ... | @@ -14972,6 +14981,8 @@ snapshots: |
| 14972 | 14981 | dependencies: |
| 14973 | 14982 | '@types/node': 24.12.4 |
| 14974 | 14983 | |
| 14984 | '@types/culori@4.0.1': {} | |
| 14985 | ||
| 14975 | 14986 | '@types/debug@4.1.13': |
| 14976 | 14987 | dependencies: |
| 14977 | 14988 | '@types/ms': 2.1.0 |