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