| 1 | import type { UiThemePreferences } from "@t3tools/contracts"; |
| 2 | import * as Equal from "effect/Equal"; |
| 3 | import * as Schema from "effect/Schema"; |
| 4 | import "culori/css"; |
| 5 | import { converter, parse } from "culori/fn"; |
| 6 | import { |
| 7 | BUILT_IN_THEMES, |
| 8 | EMBER_THEME, |
| 9 | GROVE_THEME, |
| 10 | IRIS_THEME, |
| 11 | OCEAN_THEME, |
| 12 | T3_CHAT_THEME, |
| 13 | T3_CODE_LIGHT_THEME_COLORS, |
| 14 | T3_CODE_DARK_THEME_COLORS, |
| 15 | RESERVED_THEME_IDS, |
| 16 | THEME_COLOR_ROLES, |
| 17 | type ThemeAppearance, |
| 18 | type ThemeColorRole, |
| 19 | type ThemeColors, |
| 20 | type ThemeDefinition, |
| 21 | type ThemeVariants, |
| 22 | } from "@t3tools/shared/themePalettes"; |
| 23 | |
| 24 | export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES }; |
| 25 | export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; |
| 26 | |
| 27 | export const T3_CHAT_THEME_ID = "t3-chat" as const; |
| 28 | const GROVE_THEME_ID = "grove" as const; |
| 29 | export const OCEAN_THEME_ID = "ocean" as const; |
| 30 | const EMBER_THEME_ID = "ember" as const; |
| 31 | const IRIS_THEME_ID = "iris" as const; |
| 32 | export const THEME_FILE_VERSION = 1 as const; |
| 33 | export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; |
| 34 | export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; |
| 35 | export const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode"; |
| 36 | export const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1"; |
| 37 | |
| 38 | const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark"; |
| 39 | |
| 40 | export const ThemePreference = Schema.String; |
| 41 | export type ThemePreference = typeof ThemePreference.Type; |
| 42 | |
| 43 | const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES); |
| 44 | export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>; |
| 45 | export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>; |
| 46 | export type ThemePreferenceMode = ThemeAppearance | "system"; |
| 47 | export type ThemeCollection = Readonly<{ id: string; label: string }>; |
| 48 | export type ThemeFile = Readonly<{ |
| 49 | version: typeof THEME_FILE_VERSION; |
| 50 | id: string; |
| 51 | name: string; |
| 52 | appearance: ThemeAppearance; |
| 53 | colors: ThemeColorOverrides; |
| 54 | variants?: ThemeVariantOverrides; |
| 55 | collection?: ThemeCollection; |
| 56 | managed?: boolean; |
| 57 | }>; |
| 58 | |
| 59 | // Reserved ids come from shared so the CLI, the server watcher, and this |
| 60 | // library cannot drift on what a published theme may be called. |
| 61 | |
| 62 | /** |
| 63 | * The environment's palettes are not saved: they are republished by the |
| 64 | * server on every change and would go stale the moment the machine's theme |
| 65 | * moved on. They ride the custom-theme listeners so every theme consumer |
| 66 | * already re-reads when they change. |
| 67 | */ |
| 68 | let environmentThemeDefinitions: ReadonlyArray<ThemeDefinition> = []; |
| 69 | |
| 70 | const customThemeListeners = new Set<() => void>(); |
| 71 | type CustomThemeLibrarySnapshot = |
| 72 | | Readonly<{ |
| 73 | status: "ready"; |
| 74 | storedThemes: ReadonlyArray<unknown>; |
| 75 | themes: ReadonlyArray<ThemeDefinition>; |
| 76 | }> |
| 77 | | Readonly<{ status: "unavailable"; reason: "malformed" }> |
| 78 | | Readonly<{ status: "unavailable"; reason: "storage-unavailable"; cause: unknown }>; |
| 79 | |
| 80 | let customThemeLibrarySnapshot: CustomThemeLibrarySnapshot | null = null; |
| 81 | const themePreviewListeners = new Set<() => void>(); |
| 82 | let themePreviewSidebarArtwork: boolean | null = null; |
| 83 | |
| 84 | export function getThemePreviewSidebarArtwork(): boolean | null { |
| 85 | return themePreviewSidebarArtwork; |
| 86 | } |
| 87 | |
| 88 | export function subscribeToThemePreview(listener: () => void): () => void { |
| 89 | themePreviewListeners.add(listener); |
| 90 | return () => themePreviewListeners.delete(listener); |
| 91 | } |
| 92 | |
| 93 | function setThemePreviewSidebarArtwork(next: boolean | null): void { |
| 94 | if (themePreviewSidebarArtwork === next) return; |
| 95 | themePreviewSidebarArtwork = next; |
| 96 | for (const listener of themePreviewListeners) listener(); |
| 97 | } |
| 98 | |
| 99 | function isRecord(value: unknown): value is Record<string, unknown> { |
| 100 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 101 | } |
| 102 | |
| 103 | function isThemeAppearance(value: unknown): value is ThemeAppearance { |
| 104 | return value === "light" || value === "dark"; |
| 105 | } |
| 106 | |
| 107 | export function isThemeColor(value: unknown): value is string { |
| 108 | return typeof value === "string" && toCanonicalThemeColor(value) !== null; |
| 109 | } |
| 110 | |
| 111 | function isThemeId(value: unknown): value is string { |
| 112 | return typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value); |
| 113 | } |
| 114 | |
| 115 | function isThemeLabel(value: unknown): value is string { |
| 116 | return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48; |
| 117 | } |
| 118 | |
| 119 | function parseThemeCollection(value: unknown): ThemeCollection | undefined { |
| 120 | return isRecord(value) && |
| 121 | typeof value.id === "string" && |
| 122 | /^[a-z0-9][a-z0-9.:-]{0,127}$/i.test(value.id) && |
| 123 | isThemeLabel(value.label) |
| 124 | ? { id: value.id, label: value.label.trim() } |
| 125 | : undefined; |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Tolerates unknown roles and malformed values so themes written by other |
| 130 | * builds (for example one that adds a new role) keep their remaining colors. |
| 131 | * The one canonicalization path for every externally supplied color record: |
| 132 | * stored themes, imported files, and environment-published themes. |
| 133 | */ |
| 134 | export function lenientThemeColorOverrides( |
| 135 | value: Readonly<Record<string, unknown>>, |
| 136 | ): Partial<Record<ThemeColorRole, string>> { |
| 137 | const overrides: Partial<Record<ThemeColorRole, string>> = {}; |
| 138 | for (const [role, color] of Object.entries(value)) { |
| 139 | const normalized = toCanonicalThemeColor(color); |
| 140 | if (THEME_COLOR_ROLE_SET.has(role) && normalized) { |
| 141 | overrides[role as ThemeColorRole] = normalized; |
| 142 | } |
| 143 | } |
| 144 | return overrides; |
| 145 | } |
| 146 | |
| 147 | function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { |
| 148 | if (!isRecord(value)) return null; |
| 149 | return { ...getDefaultThemeColors(appearance), ...lenientThemeColorOverrides(value) }; |
| 150 | } |
| 151 | |
| 152 | function parseStoredThemeVariants( |
| 153 | value: unknown, |
| 154 | baseAppearance: ThemeAppearance, |
| 155 | ): ThemeVariants | null | undefined { |
| 156 | if (value === undefined) return undefined; |
| 157 | if (!isRecord(value)) return null; |
| 158 | |
| 159 | const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {}; |
| 160 | for (const [appearance, colors] of Object.entries(value)) { |
| 161 | if (!isThemeAppearance(appearance)) return null; |
| 162 | // A variant matching the base appearance would be shadowed by the base |
| 163 | // colors; drop it so the theme round-trips through parseThemeFile. |
| 164 | if (appearance === baseAppearance) continue; |
| 165 | const parsedColors = parseStoredThemeColors(colors, appearance); |
| 166 | if (!parsedColors) return null; |
| 167 | variants[appearance] = parsedColors; |
| 168 | } |
| 169 | return Object.keys(variants).length > 0 ? variants : undefined; |
| 170 | } |
| 171 | |
| 172 | function parseStoredTheme(value: unknown): ThemeDefinition | null { |
| 173 | if (!isRecord(value)) return null; |
| 174 | if (!isThemeId(value.id) || RESERVED_THEME_IDS.has(value.id)) return null; |
| 175 | if (!isThemeLabel(value.label) || !isThemeAppearance(value.appearance)) return null; |
| 176 | const colors = parseStoredThemeColors(value.colors, value.appearance); |
| 177 | if (!colors) return null; |
| 178 | const variants = parseStoredThemeVariants(value.variants, value.appearance); |
| 179 | if (value.variants !== undefined && variants === null) return null; |
| 180 | const collection = parseThemeCollection(value.collection); |
| 181 | |
| 182 | return { |
| 183 | id: value.id, |
| 184 | label: value.label.trim(), |
| 185 | appearance: value.appearance, |
| 186 | colors, |
| 187 | ...(variants ? { variants } : {}), |
| 188 | ...(collection ? { collection } : {}), |
| 189 | ...(value.managed === true ? { managed: true } : {}), |
| 190 | }; |
| 191 | } |
| 192 | |
| 193 | function parseStoredThemes(storedThemes: ReadonlyArray<unknown>): ReadonlyArray<ThemeDefinition> { |
| 194 | const themes: ThemeDefinition[] = []; |
| 195 | for (const value of storedThemes) { |
| 196 | const theme = parseStoredTheme(value); |
| 197 | if (theme && !themes.some((existing) => existing.id === theme.id)) { |
| 198 | themes.push(theme); |
| 199 | } |
| 200 | } |
| 201 | return themes; |
| 202 | } |
| 203 | |
| 204 | function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { |
| 205 | if (typeof window === "undefined") { |
| 206 | return { status: "ready", storedThemes: [], themes: [] }; |
| 207 | } |
| 208 | |
| 209 | let raw: string | null; |
| 210 | try { |
| 211 | raw = window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY); |
| 212 | } catch (cause) { |
| 213 | return { status: "unavailable", reason: "storage-unavailable", cause }; |
| 214 | } |
| 215 | if (!raw) return { status: "ready", storedThemes: [], themes: [] }; |
| 216 | |
| 217 | let parsed: unknown; |
| 218 | try { |
| 219 | parsed = JSON.parse(raw); |
| 220 | } catch { |
| 221 | return { status: "unavailable", reason: "malformed" }; |
| 222 | } |
| 223 | if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" }; |
| 224 | |
| 225 | return { status: "ready", storedThemes: parsed, themes: parseStoredThemes(parsed) }; |
| 226 | } |
| 227 | |
| 228 | function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { |
| 229 | if (customThemeLibrarySnapshot === null) { |
| 230 | customThemeLibrarySnapshot = readCustomThemeLibrarySnapshot(); |
| 231 | } |
| 232 | return customThemeLibrarySnapshot; |
| 233 | } |
| 234 | |
| 235 | function notifyCustomThemeListeners() { |
| 236 | for (const listener of customThemeListeners) listener(); |
| 237 | } |
| 238 | |
| 239 | export function invalidateCustomThemes() { |
| 240 | customThemeLibrarySnapshot = null; |
| 241 | notifyCustomThemeListeners(); |
| 242 | } |
| 243 | |
| 244 | const EMPTY_CUSTOM_THEMES: ReadonlyArray<ThemeDefinition> = []; |
| 245 | |
| 246 | const customThemeEditListeners = new Set<() => void>(); |
| 247 | export function subscribeToCustomThemeEdits(listener: () => void) { |
| 248 | customThemeEditListeners.add(listener); |
| 249 | return () => { |
| 250 | customThemeEditListeners.delete(listener); |
| 251 | }; |
| 252 | } |
| 253 | |
| 254 | export function getCustomThemePreferences( |
| 255 | selected: ReadonlyArray<string>, |
| 256 | ): UiThemePreferences["customThemes"] { |
| 257 | const library = getWritableCustomThemeLibrary(); |
| 258 | const selectedIds = new Set(selected.map(themeIdFromPreference)); |
| 259 | const published = environmentThemeDefinitions.filter( |
| 260 | (theme) => |
| 261 | selectedIds.has(theme.id) && !library.themes.some((custom) => custom.id === theme.id), |
| 262 | ); |
| 263 | return [...library.themes, ...published].map( |
| 264 | ({ id, label, appearance, colors, variants, collection, managed }) => ({ |
| 265 | id, |
| 266 | name: label, |
| 267 | appearance, |
| 268 | colors, |
| 269 | ...(variants ? { variants } : {}), |
| 270 | ...(collection ? { collection } : {}), |
| 271 | ...(managed === undefined ? {} : { managed }), |
| 272 | }), |
| 273 | ); |
| 274 | } |
| 275 | |
| 276 | export function replaceCustomThemePreferences(themes: UiThemePreferences["customThemes"]) { |
| 277 | const definitions = themes.map(({ name, ...theme }) => ({ ...theme, label: name })); |
| 278 | const canonical = parseStoredThemes(definitions); |
| 279 | if (canonical.length !== definitions.length) throw new Error("Unable to apply saved themes."); |
| 280 | const library = getWritableCustomThemeLibrary(); |
| 281 | const stored = canonical.filter( |
| 282 | (theme) => |
| 283 | library.themes.some((custom) => custom.id === theme.id) || |
| 284 | !environmentThemeDefinitions.some((published) => |
| 285 | Equal.equals(canonicalizeThemeDefinition(published), theme), |
| 286 | ), |
| 287 | ); |
| 288 | saveCustomThemes(stored, stored, "sync"); |
| 289 | } |
| 290 | |
| 291 | export function getCustomThemes(): ReadonlyArray<ThemeDefinition> { |
| 292 | const snapshot = getCustomThemeLibrarySnapshot(); |
| 293 | return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES; |
| 294 | } |
| 295 | |
| 296 | export function getEnvironmentThemes(): ReadonlyArray<ThemeDefinition> { |
| 297 | return environmentThemeDefinitions; |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * Returns whether anything changed, structurally: config snapshots arrive as |
| 302 | * fresh arrays on every reconnect, and a repaint for identical colors is the |
| 303 | * kind of wasted work users of this product notice. |
| 304 | */ |
| 305 | export function setEnvironmentThemes(themes: ReadonlyArray<ThemeDefinition>): boolean { |
| 306 | if (Equal.equals(environmentThemeDefinitions, themes)) return false; |
| 307 | environmentThemeDefinitions = themes; |
| 308 | notifyCustomThemeListeners(); |
| 309 | return true; |
| 310 | } |
| 311 | |
| 312 | /** Ids no published theme may occupy: appearance keywords and built-in ids. */ |
| 313 | export function isReservedThemeId(themeId: string): boolean { |
| 314 | return RESERVED_THEME_IDS.has(themeId); |
| 315 | } |
| 316 | |
| 317 | export function getStoredCustomThemeCollection( |
| 318 | collectionId: string, |
| 319 | ): ReadonlyArray<ThemeDefinition> { |
| 320 | return readWritableCustomThemeLibrary().themes.filter( |
| 321 | (theme) => theme.collection?.id === collectionId, |
| 322 | ); |
| 323 | } |
| 324 | |
| 325 | export function subscribeToCustomThemes(listener: () => void): () => void { |
| 326 | customThemeListeners.add(listener); |
| 327 | if (typeof window === "undefined") { |
| 328 | return () => customThemeListeners.delete(listener); |
| 329 | } |
| 330 | const handleStorage = (event: StorageEvent) => { |
| 331 | if (event.key === CUSTOM_THEMES_STORAGE_KEY || event.key === null) { |
| 332 | invalidateCustomThemes(); |
| 333 | } |
| 334 | }; |
| 335 | window.addEventListener("storage", handleStorage); |
| 336 | |
| 337 | return () => { |
| 338 | customThemeListeners.delete(listener); |
| 339 | window.removeEventListener("storage", handleStorage); |
| 340 | }; |
| 341 | } |
| 342 | |
| 343 | // Earlier builds shipped every maintainer theme under a t3- prefix; only the |
| 344 | // genuinely T3-branded palette keeps it. Stored preferences and mixes with the |
| 345 | // old ids stay readable through this alias table. |
| 346 | const LEGACY_THEME_ID_ALIASES: Readonly<Record<string, string>> = { |
| 347 | [LEGACY_T3_CHAT_DARK_THEME_ID]: T3_CHAT_THEME_ID, |
| 348 | "t3-grove": GROVE_THEME_ID, |
| 349 | "t3-ocean": OCEAN_THEME_ID, |
| 350 | "t3-ember": EMBER_THEME_ID, |
| 351 | "t3-iris": IRIS_THEME_ID, |
| 352 | }; |
| 353 | |
| 354 | function normalizeThemeId(themeId: string): string { |
| 355 | return LEGACY_THEME_ID_ALIASES[themeId] ?? themeId; |
| 356 | } |
| 357 | |
| 358 | /** |
| 359 | * Map a stored preference onto the id the runtime applies, so selection state |
| 360 | * matches the theme cards. The legacy dark-variant id stays as-is because it |
| 361 | * still carries the appearance hint getThemePreferenceMode reads. |
| 362 | */ |
| 363 | export function canonicalThemePreference(theme: string): string { |
| 364 | return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? theme : normalizeThemeId(theme); |
| 365 | } |
| 366 | |
| 367 | function themeIdFromPreference(theme: ThemePreference): string { |
| 368 | return normalizeThemeId(theme); |
| 369 | } |
| 370 | |
| 371 | // Older builds stored the dark T3 Chat palette as a separate theme. Keep |
| 372 | // those preferences readable while mapping them to the dark variant. |
| 373 | function legacyThemeMode(theme: ThemePreference): ThemeAppearance | null { |
| 374 | return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? "dark" : null; |
| 375 | } |
| 376 | |
| 377 | /** |
| 378 | * The standard T3 Code look as a theme palette, for seeding a new theme when |
| 379 | * no theme is installed. Distinct from {@link getDefaultThemeColors}, which |
| 380 | * carries the flagship T3 Chat palette used to fill roles omitted by theme |
| 381 | * files. |
| 382 | */ |
| 383 | export function getStandardThemeColors(appearance: ThemeAppearance): ThemeColors { |
| 384 | if (appearance === "dark") { |
| 385 | return (standardDarkThemeColors ??= decodeThemeColors(T3_CODE_DARK_THEME_COLORS)); |
| 386 | } |
| 387 | return (standardLightThemeColors ??= decodeThemeColors(T3_CODE_LIGHT_THEME_COLORS)); |
| 388 | } |
| 389 | |
| 390 | type ThemeRgbColor = { |
| 391 | r: number; |
| 392 | g: number; |
| 393 | b: number; |
| 394 | }; |
| 395 | |
| 396 | type ThemeOklch = { L: number; C: number; h: number }; |
| 397 | type ParsedThemeColor = { color: ThemeOklch; alpha: number }; |
| 398 | |
| 399 | let standardLightThemeColors: ThemeColors | undefined; |
| 400 | let standardDarkThemeColors: ThemeColors | undefined; |
| 401 | |
| 402 | const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 }; |
| 403 | const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 }; |
| 404 | const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 }; |
| 405 | const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 }; |
| 406 | |
| 407 | const convertToOklch = converter("oklch"); |
| 408 | |
| 409 | function parseThemeColor(value: unknown): ParsedThemeColor | null { |
| 410 | if (typeof value !== "string") return null; |
| 411 | const input = value.trim(); |
| 412 | const parsed = parse(input); |
| 413 | if (!parsed) return null; |
| 414 | const color = convertToOklch(parsed); |
| 415 | const lightness = color.l ?? 0; |
| 416 | const chroma = color.c ?? 0; |
| 417 | const hue = color.h ?? 0; |
| 418 | // CSS missing components behave as zero outside interpolation. Culori omits |
| 419 | // a `none` alpha from its parsed object, so distinguish it from omitted alpha. |
| 420 | const alpha = /\/\s*none\s*\)$/i.test(input) ? 0 : (color.alpha ?? 1); |
| 421 | if (![lightness, chroma, hue, alpha].every(Number.isFinite)) return null; |
| 422 | return { |
| 423 | color: { |
| 424 | L: Math.min(1, Math.max(0, lightness)), |
| 425 | C: Math.max(0, chroma), |
| 426 | h: hue, |
| 427 | }, |
| 428 | alpha: Math.min(1, Math.max(0, alpha)), |
| 429 | }; |
| 430 | } |
| 431 | |
| 432 | function formatThemeColorNumber(value: number, precision: number): string { |
| 433 | const rounded = Math.abs(value) < 10 ** -precision / 2 ? 0 : value; |
| 434 | return rounded.toFixed(precision).replace(/(?:\.0+|(?:(\.[0-9]*?)0+))$/, "$1"); |
| 435 | } |
| 436 | |
| 437 | function formatOklchThemeColor(color: ThemeOklch, alpha = 1): string { |
| 438 | const normalizedHue = color.C < 0.0000005 ? 0 : ((color.h % 360) + 360) % 360; |
| 439 | const body = `${formatThemeColorNumber(color.L, 6)} ${formatThemeColorNumber(color.C, 6)} ${formatThemeColorNumber(normalizedHue, 3)}`; |
| 440 | return alpha < 1 ? `oklch(${body} / ${formatThemeColorNumber(alpha, 4)})` : `oklch(${body})`; |
| 441 | } |
| 442 | |
| 443 | /** |
| 444 | * Decode a literal CSS color into the runtime's canonical OKLCH form. Stored |
| 445 | * values use this path in memory without mutating localStorage. |
| 446 | */ |
| 447 | export function toCanonicalThemeColor(value: unknown): string | null { |
| 448 | const parsed = parseThemeColor(value); |
| 449 | return parsed ? formatOklchThemeColor(parsed.color, parsed.alpha) : null; |
| 450 | } |
| 451 | |
| 452 | /** Convert a runtime theme color for hex-only editor and import adapters. */ |
| 453 | export function themeColorToHex(value: string): string | null { |
| 454 | const color = parseThemeColor(value); |
| 455 | const parsed = color ? { rgb: themeOklchToRgb(color.color), alpha: color.alpha } : null; |
| 456 | if (!parsed) return null; |
| 457 | |
| 458 | const opaque = themeRgbToHexColor(parsed.rgb); |
| 459 | if (parsed.alpha >= 1) return opaque; |
| 460 | const alpha = Math.round(parsed.alpha * 255) |
| 461 | .toString(16) |
| 462 | .padStart(2, "0"); |
| 463 | return `${opaque}${alpha}`; |
| 464 | } |
| 465 | |
| 466 | function parseThemeRgbColor(value: string, fallback: ThemeRgbColor): ThemeRgbColor { |
| 467 | const parsed = parseThemeColor(value); |
| 468 | return parsed ? themeOklchToRgb(parsed.color) : fallback; |
| 469 | } |
| 470 | |
| 471 | function themeRgbToHexColor(color: ThemeRgbColor): string { |
| 472 | return `#${[color.r, color.g, color.b] |
| 473 | .map((channel) => |
| 474 | Math.round(Math.min(255, Math.max(0, channel))) |
| 475 | .toString(16) |
| 476 | .padStart(2, "0"), |
| 477 | ) |
| 478 | .join("")}`; |
| 479 | } |
| 480 | |
| 481 | function themeRgbToThemeColor(color: ThemeRgbColor): string { |
| 482 | return formatOklchThemeColor(themeRgbToOklch(color)); |
| 483 | } |
| 484 | |
| 485 | function decodeThemeColors(colors: ThemeColors): ThemeColors { |
| 486 | return Object.fromEntries( |
| 487 | THEME_COLOR_ROLES.map((role) => { |
| 488 | const color = toCanonicalThemeColor(colors[role]); |
| 489 | if (!color) { |
| 490 | throw new Error( |
| 491 | `The color for "${role}" must be a literal CSS color such as oklch(0.62 0.2 280).`, |
| 492 | ); |
| 493 | } |
| 494 | return [role, color]; |
| 495 | }), |
| 496 | ) as Record<ThemeColorRole, string>; |
| 497 | } |
| 498 | |
| 499 | function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition { |
| 500 | return { |
| 501 | ...theme, |
| 502 | colors: decodeThemeColors(theme.colors), |
| 503 | ...(theme.variants |
| 504 | ? { |
| 505 | variants: Object.fromEntries( |
| 506 | Object.entries(theme.variants).map(([appearance, colors]) => [ |
| 507 | appearance, |
| 508 | decodeThemeColors(colors), |
| 509 | ]), |
| 510 | ) as ThemeVariants, |
| 511 | } |
| 512 | : {}), |
| 513 | }; |
| 514 | } |
| 515 | |
| 516 | function mixThemeRgbColors( |
| 517 | base: ThemeRgbColor, |
| 518 | overlay: ThemeRgbColor, |
| 519 | amount: number, |
| 520 | ): ThemeRgbColor { |
| 521 | return { |
| 522 | r: base.r + (overlay.r - base.r) * amount, |
| 523 | g: base.g + (overlay.g - base.g) * amount, |
| 524 | b: base.b + (overlay.b - base.b) * amount, |
| 525 | }; |
| 526 | } |
| 527 | |
| 528 | function themeRelativeLuminance(color: ThemeRgbColor): number { |
| 529 | const linearize = (channel: number) => { |
| 530 | const normalized = channel / 255; |
| 531 | return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; |
| 532 | }; |
| 533 | |
| 534 | return 0.2126 * linearize(color.r) + 0.7152 * linearize(color.g) + 0.0722 * linearize(color.b); |
| 535 | } |
| 536 | |
| 537 | // --------------------------------------------------------------------------- |
| 538 | // Vivid palette engine: perceptual (OKLCH) derivation for user-created themes. |
| 539 | |
| 540 | function srgbChannelToLinear(channel: number): number { |
| 541 | const c = channel / 255; |
| 542 | return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; |
| 543 | } |
| 544 | |
| 545 | function linearChannelToSrgb(channel: number): number { |
| 546 | const c = channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055; |
| 547 | return Math.round(Math.min(1, Math.max(0, c)) * 255); |
| 548 | } |
| 549 | |
| 550 | function themeRgbToOklch(color: ThemeRgbColor): ThemeOklch { |
| 551 | const r = srgbChannelToLinear(color.r); |
| 552 | const g = srgbChannelToLinear(color.g); |
| 553 | const b = srgbChannelToLinear(color.b); |
| 554 | const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); |
| 555 | const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); |
| 556 | const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); |
| 557 | const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; |
| 558 | const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; |
| 559 | const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; |
| 560 | return { L, C: Math.hypot(a, bb), h: (Math.atan2(bb, a) * 180) / Math.PI }; |
| 561 | } |
| 562 | |
| 563 | function oklchToRgbUnclamped({ L, C, h }: ThemeOklch): { r: number; g: number; b: number } { |
| 564 | const hr = (h * Math.PI) / 180; |
| 565 | const a = C * Math.cos(hr); |
| 566 | const bb = C * Math.sin(hr); |
| 567 | const l = (L + 0.3963377774 * a + 0.2158037573 * bb) ** 3; |
| 568 | const m = (L - 0.1055613458 * a - 0.0638541728 * bb) ** 3; |
| 569 | const s = (L - 0.0894841775 * a - 1.291485548 * bb) ** 3; |
| 570 | return { |
| 571 | r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, |
| 572 | g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, |
| 573 | b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, |
| 574 | }; |
| 575 | } |
| 576 | |
| 577 | /** Find the greatest chroma along the same lightness and hue that fits in sRGB. */ |
| 578 | function mapThemeOklchToSrgbGamut(color: ThemeOklch): ThemeOklch { |
| 579 | const isInGamut = (C: number) => { |
| 580 | const linear = oklchToRgbUnclamped({ ...color, C }); |
| 581 | return [linear.r, linear.g, linear.b].every( |
| 582 | (channel) => channel >= -0.0001 && channel <= 1.0001, |
| 583 | ); |
| 584 | }; |
| 585 | if (isInGamut(color.C)) return color; |
| 586 | |
| 587 | let low = 0; |
| 588 | let high = color.C; |
| 589 | const chromaResolution = 0.000001; |
| 590 | const steps = Math.max( |
| 591 | 1, |
| 592 | Math.ceil(Math.log2(Math.max(color.C, chromaResolution)) - Math.log2(chromaResolution)), |
| 593 | ); |
| 594 | for (let step = 0; step < steps; step += 1) { |
| 595 | const mid = (low + high) / 2; |
| 596 | if (isInGamut(mid)) low = mid; |
| 597 | else high = mid; |
| 598 | } |
| 599 | return { ...color, C: low }; |
| 600 | } |
| 601 | |
| 602 | /** Convert to sRGB after applying the palette engine's gamut mapping. */ |
| 603 | function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor { |
| 604 | const linear = oklchToRgbUnclamped(mapThemeOklchToSrgbGamut(color)); |
| 605 | return { |
| 606 | r: linearChannelToSrgb(linear.r), |
| 607 | g: linearChannelToSrgb(linear.g), |
| 608 | b: linearChannelToSrgb(linear.b), |
| 609 | }; |
| 610 | } |
| 611 | |
| 612 | function themeOklchToThemeColor(color: ThemeOklch): string { |
| 613 | return formatOklchThemeColor(mapThemeOklchToSrgbGamut(color)); |
| 614 | } |
| 615 | |
| 616 | /** Binary-search the lightness that reaches the contrast target against a background. */ |
| 617 | function solveOklchLightness( |
| 618 | base: ThemeOklch, |
| 619 | against: ThemeRgbColor, |
| 620 | minContrast: number, |
| 621 | direction: "lighter" | "darker", |
| 622 | ): ThemeOklch { |
| 623 | let low = direction === "lighter" ? base.L : 0; |
| 624 | let high = direction === "lighter" ? 1 : base.L; |
| 625 | let candidate = { ...base }; |
| 626 | if (themeContrastRatio(themeOklchToRgb(candidate), against) >= minContrast) return candidate; |
| 627 | for (let step = 0; step < 18; step += 1) { |
| 628 | const mid = (low + high) / 2; |
| 629 | candidate = { ...base, L: mid }; |
| 630 | const contrast = themeContrastRatio(themeOklchToRgb(candidate), against); |
| 631 | if (contrast >= minContrast) { |
| 632 | if (direction === "lighter") high = mid; |
| 633 | else low = mid; |
| 634 | } else { |
| 635 | if (direction === "lighter") low = mid; |
| 636 | else high = mid; |
| 637 | } |
| 638 | } |
| 639 | return { ...base, L: direction === "lighter" ? high : low }; |
| 640 | } |
| 641 | |
| 642 | /** |
| 643 | * The status colors T3 Code shows without a theme, read from the app's own |
| 644 | * tokens (red-500 / amber-500 families). Generated palettes fall back to |
| 645 | * these instead of the flagship theme's, so an imported or created theme |
| 646 | * never inherits a brand tint on destructive buttons and warnings. |
| 647 | */ |
| 648 | const STANDARD_STATUS_COLORS = { |
| 649 | light: { |
| 650 | error: "#fb2c36", |
| 651 | errorForeground: "#c10007", |
| 652 | warning: "#fe9a00", |
| 653 | warningForeground: "#bb4d00", |
| 654 | }, |
| 655 | dark: { |
| 656 | error: "#fb414a", |
| 657 | errorForeground: "#ff6467", |
| 658 | warning: "#fe9a00", |
| 659 | warningForeground: "#ffb900", |
| 660 | }, |
| 661 | } as const; |
| 662 | |
| 663 | /** |
| 664 | * Status surfaces are the standard color laid over the theme's own canvas |
| 665 | * (the unthemed app uses 8% in light and 16% in dark), so alerts still sit on |
| 666 | * the palette while the signal color stays standard. |
| 667 | */ |
| 668 | function standardStatusColors(canvas: ThemeRgbColor): { |
| 669 | error: string; |
| 670 | errorForeground: string; |
| 671 | errorSurface: string; |
| 672 | warning: string; |
| 673 | warningForeground: string; |
| 674 | warningSurface: string; |
| 675 | } { |
| 676 | // Keyed off the canvas rather than the appearance slot: a dark canvas saved |
| 677 | // as a light theme still needs the dark pair, or the alert foreground lands |
| 678 | // on a dark surface unreadable. |
| 679 | const appearance: ThemeAppearance = themeRelativeLuminance(canvas) < 0.179 ? "dark" : "light"; |
| 680 | const standard = STANDARD_STATUS_COLORS[appearance]; |
| 681 | const surfaceMix = appearance === "dark" ? 0.16 : 0.08; |
| 682 | const surfaceOf = (value: string) => |
| 683 | mixThemeRgbColors(canvas, parseThemeRgbColor(value, canvas), surfaceMix); |
| 684 | // The standard foregrounds are tuned against the unthemed canvas; on a |
| 685 | // tinted one they can fall just short, so lightness is nudged until the |
| 686 | // pair clears 4.5 while the hue stays standard. |
| 687 | const readableOn = (foreground: string, surface: ThemeRgbColor) => |
| 688 | themeOklchToThemeColor( |
| 689 | solveOklchLightness( |
| 690 | themeRgbToOklch(parseThemeRgbColor(foreground, canvas)), |
| 691 | surface, |
| 692 | // Leave a little headroom for browser color conversion at render time. |
| 693 | 4.6, |
| 694 | appearance === "dark" ? "lighter" : "darker", |
| 695 | ), |
| 696 | ); |
| 697 | const errorSurface = surfaceOf(standard.error); |
| 698 | const warningSurface = surfaceOf(standard.warning); |
| 699 | return { |
| 700 | error: toCanonicalThemeColor(standard.error)!, |
| 701 | errorForeground: readableOn(standard.errorForeground, errorSurface), |
| 702 | errorSurface: themeRgbToThemeColor(errorSurface), |
| 703 | warning: toCanonicalThemeColor(standard.warning)!, |
| 704 | warningForeground: readableOn(standard.warningForeground, warningSurface), |
| 705 | warningSurface: themeRgbToThemeColor(warningSurface), |
| 706 | }; |
| 707 | } |
| 708 | |
| 709 | /** |
| 710 | * Derive a full palette from two exact seed colors, in OKLCH. Surfaces climb a |
| 711 | * perceptually even lightness ramp that carries the accent hue at low chroma, |
| 712 | * a companion action color is rotated off the accent, and every foreground is |
| 713 | * contrast-solved against its own surface. |
| 714 | */ |
| 715 | export function createVividThemeColors( |
| 716 | appearance: ThemeAppearance, |
| 717 | backgroundValue: string, |
| 718 | accentValue: string, |
| 719 | ): ThemeColors { |
| 720 | const defaults = getDefaultThemeColors(appearance); |
| 721 | const canvasRgb = parseThemeRgbColor( |
| 722 | backgroundValue, |
| 723 | appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, |
| 724 | ); |
| 725 | const accentRgb = parseThemeRgbColor(accentValue, { r: 168, g: 67, b: 112 }); |
| 726 | const canvas = themeRgbToOklch(canvasRgb); |
| 727 | const accent = themeRgbToOklch(accentRgb); |
| 728 | // The ramp and every contrast search follow the canvas the user actually |
| 729 | // picked, not the appearance slot, so a dark canvas saved as a light theme |
| 730 | // still gets light text and raised surfaces. 0.179 is the relative |
| 731 | // luminance where white and black text have equal contrast headroom. |
| 732 | const dark = themeRelativeLuminance(canvasRgb) < 0.179; |
| 733 | const hue = accent.C < 0.02 ? canvas.h : accent.h; |
| 734 | const tintC = Math.min(0.045, Math.max(0.008, accent.C * 0.22)); |
| 735 | const step = dark ? 1 : -1; |
| 736 | |
| 737 | const surfaceAt = (deltaL: number, chroma = tintC): ThemeOklch => ({ |
| 738 | L: Math.min(0.98, Math.max(0.05, canvas.L + step * deltaL)), |
| 739 | C: chroma, |
| 740 | h: hue, |
| 741 | }); |
| 742 | const themeColor = (color: ThemeOklch) => themeOklchToThemeColor(color); |
| 743 | |
| 744 | // Text carries a whisper of the accent hue instead of falling back to a |
| 745 | // fixed foreground, and is solved to WCAG AAA against the canvas. |
| 746 | const textBase: ThemeOklch = { |
| 747 | L: dark ? 0.95 : 0.2, |
| 748 | C: Math.min(0.035, accent.C * 0.25), |
| 749 | h: hue, |
| 750 | }; |
| 751 | const text = solveOklchLightness(textBase, canvasRgb, 7, dark ? "lighter" : "darker"); |
| 752 | const textRgb = themeOklchToRgb(text); |
| 753 | const textMutedRgb = standardMutedThemeText(canvasRgb, textRgb); |
| 754 | |
| 755 | // The companion action rotates off the accent so a two-color theme still |
| 756 | // gets the dual-voice character of the hand-tuned palettes. |
| 757 | const action: ThemeOklch = { |
| 758 | L: Math.min(0.85, Math.max(0.35, accent.L + (dark ? 0.06 : -0.02))), |
| 759 | C: Math.max(accent.C * 0.9, 0.06), |
| 760 | h: (hue + 50) % 360, |
| 761 | }; |
| 762 | const actionRgb = themeOklchToRgb(action); |
| 763 | const actionForeground = readableThemeForeground(actionRgb); |
| 764 | const accentForeground = readableThemeForeground(accentRgb); |
| 765 | |
| 766 | const sidebar = surfaceAt(0.045, tintC * 1.4); |
| 767 | const sidebarRgb = themeOklchToRgb(sidebar); |
| 768 | const surface = surfaceAt(0.015); |
| 769 | const surfaceRaised = surfaceAt(0.05); |
| 770 | const surfaceRaisedRgb = themeOklchToRgb(surfaceRaised); |
| 771 | const surfaceOverlay = surfaceAt(0.075); |
| 772 | const border = surfaceAt(dark ? 0.16 : 0.12, Math.min(0.07, accent.C * 0.35)); |
| 773 | const input = surfaceAt(dark ? 0.21 : 0.16, Math.min(0.08, accent.C * 0.4)); |
| 774 | const secondary = surfaceAt(dark ? 0.1 : 0.06, Math.min(0.09, accent.C * 0.5)); |
| 775 | const secondaryRgb = themeOklchToRgb(secondary); |
| 776 | const muted = surfaceAt(dark ? 0.06 : 0.04, Math.min(0.06, accent.C * 0.35)); |
| 777 | const mutedRgb = themeOklchToRgb(muted); |
| 778 | const accentSurface = surfaceAt(dark ? 0.13 : 0.08, Math.min(0.11, accent.C * 0.55)); |
| 779 | const accentSurfaceRgb = themeOklchToRgb(accentSurface); |
| 780 | const messageSurface = surfaceAt(dark ? 0.16 : 0.1, Math.min(0.13, accent.C * 0.6)); |
| 781 | const messageSurfaceRgb = themeOklchToRgb(messageSurface); |
| 782 | const codeBackground = surfaceAt(0.035, tintC * 0.8); |
| 783 | const updateSurface = surfaceAt(dark ? 0.14 : 0.09, Math.min(0.12, accent.C * 0.55)); |
| 784 | |
| 785 | const foregroundOn = (surfaceRgb: ThemeRgbColor): string => |
| 786 | themeOklchToThemeColor( |
| 787 | solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker"), |
| 788 | ); |
| 789 | const mutedForeground = themeRgbToThemeColor(readableThemeText(mutedRgb, textRgb, 1, 4.6)); |
| 790 | const placeholder = themeRgbToThemeColor(readableThemeText(surfaceRaisedRgb, textRgb, 1, 4.6)); |
| 791 | |
| 792 | // Keep matches visible even when an imported theme has no accent colour. |
| 793 | const searchMatch = themeOklchToRgb(surfaceAt(0.22, Math.min(0.12, accent.C))); |
| 794 | const searchMatchActive = themeOklchToRgb(surfaceAt(0.4, Math.min(0.18, accent.C))); |
| 795 | |
| 796 | const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) }; |
| 797 | |
| 798 | return { |
| 799 | ...defaults, |
| 800 | ...standardStatusColors(canvasRgb), |
| 801 | canvas: themeRgbToThemeColor(canvasRgb), |
| 802 | // The top bar shares the canvas so the main panel reads as one surface. |
| 803 | chrome: themeRgbToThemeColor(canvasRgb), |
| 804 | toolbar: themeRgbToThemeColor(canvasRgb), |
| 805 | toolbarForeground: themeRgbToThemeColor(textRgb), |
| 806 | toolbarBorder: themeColor(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.08, accent.C * 0.4))), |
| 807 | toolbarControl: themeColor(surfaceAt(dark ? 0.09 : 0.05, tintC * 1.3)), |
| 808 | toolbarControlForeground: themeRgbToThemeColor(textRgb), |
| 809 | toolbarControlHover: themeColor(surfaceAt(dark ? 0.14 : 0.09, tintC * 1.6)), |
| 810 | surface: themeColor(surface), |
| 811 | surfaceRaised: themeColor(surfaceRaised), |
| 812 | surfaceOverlay: themeColor(surfaceOverlay), |
| 813 | text: themeRgbToThemeColor(textRgb), |
| 814 | textMuted: themeRgbToThemeColor(textMutedRgb), |
| 815 | border: themeColor(border), |
| 816 | input: themeColor(input), |
| 817 | focus: themeRgbToThemeColor(accentRgb), |
| 818 | accent: themeRgbToThemeColor(accentRgb), |
| 819 | accentForeground: themeRgbToThemeColor(accentForeground), |
| 820 | secondary: themeColor(secondary), |
| 821 | secondaryForeground: foregroundOn(secondaryRgb), |
| 822 | muted: themeColor(muted), |
| 823 | mutedForeground, |
| 824 | placeholder, |
| 825 | secondaryLabel: themeRgbToThemeColor(textMutedRgb), |
| 826 | iconMuted: themeRgbToThemeColor(textMutedRgb), |
| 827 | update: themeRgbToThemeColor(accentRgb), |
| 828 | updateForeground: foregroundOn(themeOklchToRgb(updateSurface)), |
| 829 | updateSurface: themeColor(updateSurface), |
| 830 | accentSurface: themeColor(accentSurface), |
| 831 | accentSurfaceForeground: foregroundOn(accentSurfaceRgb), |
| 832 | messageSurface: themeColor(messageSurface), |
| 833 | messageForeground: foregroundOn(messageSurfaceRgb), |
| 834 | messageAction: themeRgbToThemeColor(actionRgb), |
| 835 | messageActionForeground: themeRgbToThemeColor(actionForeground), |
| 836 | messageActionHover: themeColor(actionHover), |
| 837 | codeBackground: themeColor(codeBackground), |
| 838 | codeForeground: themeRgbToThemeColor(textRgb), |
| 839 | searchMatchBackground: themeRgbToThemeColor(searchMatch), |
| 840 | searchMatchForeground: themeRgbToThemeColor(readableThemeForeground(searchMatch)), |
| 841 | searchMatchActiveBackground: themeRgbToThemeColor(searchMatchActive), |
| 842 | searchMatchActiveForeground: themeRgbToThemeColor(readableThemeForeground(searchMatchActive)), |
| 843 | sidebar: themeColor(sidebar), |
| 844 | sidebarForeground: foregroundOn(sidebarRgb), |
| 845 | sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebarRgb, textRgb)), |
| 846 | sidebarControlSurface: themeColor(surfaceAt(dark ? 0.1 : 0.07, tintC * 1.5)), |
| 847 | sidebarRowHover: themeColor(surfaceAt(dark ? 0.08 : 0.06, Math.min(0.08, accent.C * 0.45))), |
| 848 | sidebarRowActive: themeColor(surfaceAt(dark ? 0.12 : 0.09, Math.min(0.1, accent.C * 0.55))), |
| 849 | sidebarRowSelected: themeColor(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.11, accent.C * 0.6))), |
| 850 | sidebarBorder: themeColor(surfaceAt(dark ? 0.17 : 0.12, Math.min(0.08, accent.C * 0.4))), |
| 851 | terminalBackground: themeRgbToThemeColor(canvasRgb), |
| 852 | terminalForeground: themeRgbToThemeColor(textRgb), |
| 853 | terminalCursor: themeRgbToThemeColor(accentRgb), |
| 854 | terminalSelection: themeColor(surfaceAt(dark ? 0.18 : 0.12, Math.min(0.12, accent.C * 0.55))), |
| 855 | terminalScrollbar: themeColor(surfaceAt(dark ? 0.22 : 0.16, tintC)), |
| 856 | terminalScrollbarHover: themeColor(surfaceAt(dark ? 0.3 : 0.22, tintC)), |
| 857 | }; |
| 858 | } |
| 859 | |
| 860 | function themeContrastRatio(first: ThemeRgbColor, second: ThemeRgbColor): number { |
| 861 | const firstLuminance = themeRelativeLuminance(first); |
| 862 | const secondLuminance = themeRelativeLuminance(second); |
| 863 | const lighter = Math.max(firstLuminance, secondLuminance); |
| 864 | const darker = Math.min(firstLuminance, secondLuminance); |
| 865 | return (lighter + 0.05) / (darker + 0.05); |
| 866 | } |
| 867 | |
| 868 | function readableThemeForeground(background: ThemeRgbColor): ThemeRgbColor { |
| 869 | const lightContrast = themeContrastRatio(background, THEME_LIGHT_FOREGROUND); |
| 870 | const darkContrast = themeContrastRatio(background, THEME_DARK_FOREGROUND); |
| 871 | if (Math.max(lightContrast, darkContrast) >= 4.5) { |
| 872 | return lightContrast >= darkContrast ? THEME_LIGHT_FOREGROUND : THEME_DARK_FOREGROUND; |
| 873 | } |
| 874 | |
| 875 | return themeContrastRatio(background, THEME_WHITE_FOREGROUND) >= |
| 876 | themeContrastRatio(background, THEME_BLACK_FOREGROUND) |
| 877 | ? THEME_WHITE_FOREGROUND |
| 878 | : THEME_BLACK_FOREGROUND; |
| 879 | } |
| 880 | |
| 881 | function readableThemeText( |
| 882 | background: ThemeRgbColor, |
| 883 | foreground: ThemeRgbColor, |
| 884 | amount: number, |
| 885 | minimumRatio: number, |
| 886 | ): ThemeRgbColor { |
| 887 | const softened = mixThemeRgbColors(foreground, background, amount); |
| 888 | if (themeContrastRatio(softened, background) >= minimumRatio) return softened; |
| 889 | |
| 890 | // Find the quietest point between the requested mix and the primary |
| 891 | // foreground that still clears the contrast floor. Returning the primary |
| 892 | // foreground here made secondary labels jump from slightly too dim to full |
| 893 | // brightness, which is especially conspicuous in dark custom themes. |
| 894 | let readable = foreground; |
| 895 | let lowerAmount = 0; |
| 896 | let upperAmount = amount; |
| 897 | for (let index = 0; index < 12; index += 1) { |
| 898 | const candidateAmount = (lowerAmount + upperAmount) / 2; |
| 899 | const candidate = mixThemeRgbColors(foreground, background, candidateAmount); |
| 900 | if (themeContrastRatio(candidate, background) >= minimumRatio) { |
| 901 | readable = candidate; |
| 902 | lowerAmount = candidateAmount; |
| 903 | } else { |
| 904 | upperAmount = candidateAmount; |
| 905 | } |
| 906 | } |
| 907 | return readable; |
| 908 | } |
| 909 | |
| 910 | // Match the perceived strength of the stock palettes rather than choosing an |
| 911 | // arbitrary foreground mix. These are the measured contrast ratios of zinc-500 |
| 912 | // on the standard light canvas and #818181 on the standard dark canvas. |
| 913 | const STANDARD_LIGHT_MUTED_CONTRAST = 4.705; |
| 914 | const STANDARD_DARK_MUTED_CONTRAST = 5.082; |
| 915 | |
| 916 | function standardMutedThemeText( |
| 917 | background: ThemeRgbColor, |
| 918 | foreground: ThemeRgbColor, |
| 919 | ): ThemeRgbColor { |
| 920 | const target = |
| 921 | themeRelativeLuminance(background) < 0.179 |
| 922 | ? STANDARD_DARK_MUTED_CONTRAST |
| 923 | : STANDARD_LIGHT_MUTED_CONTRAST; |
| 924 | return readableThemeText(background, foreground, 1, target); |
| 925 | } |
| 926 | |
| 927 | /** Theme-file defaults follow the flagship palette for the requested mode. */ |
| 928 | export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { |
| 929 | return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; |
| 930 | } |
| 931 | |
| 932 | /** |
| 933 | * Update one Advanced-editor color family without normalizing the rest of an |
| 934 | * imported or hand-tuned palette. The editor exposes a representative role |
| 935 | * for each family; paired foregrounds and nearby states are derived only when |
| 936 | * that representative is changed. |
| 937 | */ |
| 938 | export function updateThemeColorFamily( |
| 939 | appearance: ThemeAppearance, |
| 940 | colors: ThemeColors, |
| 941 | role: ThemeColorRole, |
| 942 | value: string, |
| 943 | ): ThemeColors { |
| 944 | const parsedSelected = parseThemeColor(value); |
| 945 | if (!parsedSelected) return { ...colors, [role]: value }; |
| 946 | const normalized = formatOklchThemeColor(parsedSelected.color, parsedSelected.alpha); |
| 947 | |
| 948 | const canvas = parseThemeRgbColor( |
| 949 | colors.canvas, |
| 950 | appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, |
| 951 | ); |
| 952 | const selected = themeOklchToRgb(parsedSelected.color); |
| 953 | const selectedOn = (background: ThemeRgbColor) => |
| 954 | mixThemeRgbColors(background, selected, parsedSelected.alpha); |
| 955 | const selectedOnCanvas = selectedOn(canvas); |
| 956 | const accent = parseThemeRgbColor(colors.accent, { r: 168, g: 67, b: 112 }); |
| 957 | const canvasIsDark = themeRelativeLuminance(canvas) < 0.179; |
| 958 | const terminalIsDark = themeRelativeLuminance(selectedOnCanvas) < 0.179; |
| 959 | const colorOf = (color: ThemeRgbColor) => themeRgbToThemeColor(color); |
| 960 | const foregroundOn = (background: ThemeRgbColor) => colorOf(readableThemeForeground(background)); |
| 961 | const selectedToneOn = (background: ThemeRgbColor) => |
| 962 | themeOklchToThemeColor( |
| 963 | solveOklchLightness( |
| 964 | parsedSelected.color, |
| 965 | background, |
| 966 | 4.6, |
| 967 | themeRelativeLuminance(background) < 0.179 ? "lighter" : "darker", |
| 968 | ), |
| 969 | ); |
| 970 | const statusColors = () => { |
| 971 | const surface = mixThemeRgbColors(canvas, selectedOnCanvas, canvasIsDark ? 0.16 : 0.08); |
| 972 | return { |
| 973 | foreground: selectedToneOn(surface), |
| 974 | surface: colorOf(surface), |
| 975 | }; |
| 976 | }; |
| 977 | |
| 978 | switch (role) { |
| 979 | case "canvas": |
| 980 | return { ...colors, canvas: normalized, chrome: normalized, toolbar: normalized }; |
| 981 | case "surface": |
| 982 | case "surfaceRaised": |
| 983 | case "surfaceOverlay": |
| 984 | case "input": |
| 985 | case "sidebarControlSurface": |
| 986 | return { ...colors, [role]: normalized }; |
| 987 | case "text": |
| 988 | return { |
| 989 | ...colors, |
| 990 | text: normalized, |
| 991 | toolbarForeground: normalized, |
| 992 | toolbarControlForeground: normalized, |
| 993 | }; |
| 994 | case "mutedForeground": |
| 995 | return { |
| 996 | ...colors, |
| 997 | textMuted: normalized, |
| 998 | mutedForeground: normalized, |
| 999 | placeholder: normalized, |
| 1000 | secondaryLabel: normalized, |
| 1001 | iconMuted: normalized, |
| 1002 | sidebarMutedForeground: normalized, |
| 1003 | }; |
| 1004 | case "border": |
| 1005 | return { |
| 1006 | ...colors, |
| 1007 | border: normalized, |
| 1008 | toolbarBorder: normalized, |
| 1009 | sidebarBorder: normalized, |
| 1010 | }; |
| 1011 | case "secondary": |
| 1012 | return { |
| 1013 | ...colors, |
| 1014 | secondary: normalized, |
| 1015 | secondaryForeground: foregroundOn(selectedOnCanvas), |
| 1016 | muted: normalized, |
| 1017 | toolbarControl: normalized, |
| 1018 | }; |
| 1019 | case "accentSurface": |
| 1020 | return { |
| 1021 | ...colors, |
| 1022 | accentSurface: normalized, |
| 1023 | accentSurfaceForeground: foregroundOn(selectedOnCanvas), |
| 1024 | toolbarControlHover: normalized, |
| 1025 | }; |
| 1026 | case "accent": { |
| 1027 | const updateSurface = mixThemeRgbColors(canvas, selectedOnCanvas, canvasIsDark ? 0.32 : 0.16); |
| 1028 | return { |
| 1029 | ...colors, |
| 1030 | accent: normalized, |
| 1031 | accentForeground: foregroundOn(selectedOnCanvas), |
| 1032 | focus: normalized, |
| 1033 | update: normalized, |
| 1034 | updateForeground: selectedToneOn(updateSurface), |
| 1035 | updateSurface: colorOf(updateSurface), |
| 1036 | terminalCursor: normalized, |
| 1037 | }; |
| 1038 | } |
| 1039 | case "messageAction": { |
| 1040 | const actionForeground = readableThemeForeground(selectedOnCanvas); |
| 1041 | const towardOpposite = |
| 1042 | actionForeground === THEME_LIGHT_FOREGROUND || actionForeground === THEME_WHITE_FOREGROUND |
| 1043 | ? THEME_BLACK_FOREGROUND |
| 1044 | : THEME_WHITE_FOREGROUND; |
| 1045 | const actionHover = mixThemeRgbColors(selected, towardOpposite, 0.12); |
| 1046 | return { |
| 1047 | ...colors, |
| 1048 | messageAction: normalized, |
| 1049 | messageActionForeground: colorOf(actionForeground), |
| 1050 | messageActionHover: formatOklchThemeColor( |
| 1051 | themeRgbToOklch(actionHover), |
| 1052 | parsedSelected.alpha, |
| 1053 | ), |
| 1054 | }; |
| 1055 | } |
| 1056 | case "messageSurface": |
| 1057 | return { |
| 1058 | ...colors, |
| 1059 | messageSurface: normalized, |
| 1060 | messageForeground: foregroundOn(selectedOnCanvas), |
| 1061 | }; |
| 1062 | case "searchMatchBackground": |
| 1063 | return { |
| 1064 | ...colors, |
| 1065 | searchMatchBackground: colorOf(selectedOnCanvas), |
| 1066 | searchMatchForeground: foregroundOn(selectedOnCanvas), |
| 1067 | }; |
| 1068 | case "searchMatchActiveBackground": |
| 1069 | return { |
| 1070 | ...colors, |
| 1071 | searchMatchActiveBackground: colorOf(selectedOnCanvas), |
| 1072 | searchMatchActiveForeground: foregroundOn(selectedOnCanvas), |
| 1073 | }; |
| 1074 | case "codeBackground": |
| 1075 | return { |
| 1076 | ...colors, |
| 1077 | codeBackground: normalized, |
| 1078 | codeForeground: foregroundOn(selectedOnCanvas), |
| 1079 | }; |
| 1080 | case "sidebar": |
| 1081 | return { |
| 1082 | ...colors, |
| 1083 | sidebar: normalized, |
| 1084 | sidebarForeground: foregroundOn(selectedOnCanvas), |
| 1085 | }; |
| 1086 | case "sidebarRowSelected": { |
| 1087 | const sidebar = parseThemeRgbColor(colors.sidebar, canvas); |
| 1088 | const selectedOnSidebar = selectedOn(sidebar); |
| 1089 | return { |
| 1090 | ...colors, |
| 1091 | sidebarRowHover: colorOf(mixThemeRgbColors(sidebar, selectedOnSidebar, 0.5)), |
| 1092 | sidebarRowActive: colorOf(mixThemeRgbColors(sidebar, selectedOnSidebar, 0.8)), |
| 1093 | sidebarRowSelected: normalized, |
| 1094 | }; |
| 1095 | } |
| 1096 | case "terminalBackground": { |
| 1097 | const terminalForeground = readableThemeForeground(selectedOnCanvas); |
| 1098 | return { |
| 1099 | ...colors, |
| 1100 | terminalBackground: normalized, |
| 1101 | terminalForeground: colorOf(terminalForeground), |
| 1102 | terminalSelection: colorOf( |
| 1103 | mixThemeRgbColors(selectedOnCanvas, accent, terminalIsDark ? 0.35 : 0.18), |
| 1104 | ), |
| 1105 | terminalScrollbar: colorOf( |
| 1106 | mixThemeRgbColors(selectedOnCanvas, terminalForeground, terminalIsDark ? 0.42 : 0.22), |
| 1107 | ), |
| 1108 | terminalScrollbarHover: colorOf( |
| 1109 | mixThemeRgbColors(selectedOnCanvas, terminalForeground, terminalIsDark ? 0.55 : 0.32), |
| 1110 | ), |
| 1111 | }; |
| 1112 | } |
| 1113 | case "error": { |
| 1114 | const status = statusColors(); |
| 1115 | return { |
| 1116 | ...colors, |
| 1117 | error: normalized, |
| 1118 | errorForeground: status.foreground, |
| 1119 | errorSurface: status.surface, |
| 1120 | }; |
| 1121 | } |
| 1122 | case "warning": { |
| 1123 | const status = statusColors(); |
| 1124 | return { |
| 1125 | ...colors, |
| 1126 | warning: normalized, |
| 1127 | warningForeground: status.foreground, |
| 1128 | warningSurface: status.surface, |
| 1129 | }; |
| 1130 | } |
| 1131 | default: |
| 1132 | return { ...colors, [role]: normalized }; |
| 1133 | } |
| 1134 | } |
| 1135 | |
| 1136 | const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = BUILT_IN_THEMES; |
| 1137 | |
| 1138 | export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | null { |
| 1139 | const themeId = themeIdFromPreference(theme); |
| 1140 | return ( |
| 1141 | BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ?? |
| 1142 | getCustomThemes().find((definition) => definition.id === themeId) ?? |
| 1143 | // Resolved last so a theme the user saved always wins over one the |
| 1144 | // machine happens to publish under the same id. |
| 1145 | environmentThemeDefinitions.find((definition) => definition.id === themeId) ?? |
| 1146 | null |
| 1147 | ); |
| 1148 | } |
| 1149 | |
| 1150 | /** Artwork palettes are reviewed alongside built-ins; user themes always use the pill fallback. */ |
| 1151 | export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean { |
| 1152 | const themeId = themeIdFromPreference(theme); |
| 1153 | return ( |
| 1154 | BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId)?.sidebarArtwork === |
| 1155 | true |
| 1156 | ); |
| 1157 | } |
| 1158 | |
| 1159 | /** |
| 1160 | * Which half a theme can claim, or null when it renders both appearances. |
| 1161 | * Selecting a single-appearance theme as the base preference would clear the |
| 1162 | * light/dark mix and leave the appearance tiles disagreeing with what is on |
| 1163 | * screen, so every path that selects a theme has to make the same call. |
| 1164 | */ |
| 1165 | export function singleAppearanceOf(theme: ThemeDefinition): ThemeAppearance | null { |
| 1166 | const modes = getThemeModes(theme); |
| 1167 | return modes.length === 1 ? modes[0]! : null; |
| 1168 | } |
| 1169 | |
| 1170 | export function getThemeColorsForMode( |
| 1171 | theme: ThemeDefinition, |
| 1172 | mode: ThemeAppearance, |
| 1173 | ): ThemeColors | null { |
| 1174 | if (mode === theme.appearance) return theme.colors; |
| 1175 | return theme.variants?.[mode] ?? null; |
| 1176 | } |
| 1177 | |
| 1178 | export function getThemeModes(theme: ThemeDefinition): ReadonlyArray<ThemeAppearance> { |
| 1179 | return (["light", "dark"] as const).filter((mode) => getThemeColorsForMode(theme, mode) !== null); |
| 1180 | } |
| 1181 | |
| 1182 | export function getThemePreferenceMode(theme: ThemePreference): ThemeAppearance | null { |
| 1183 | if (theme === "system") return null; |
| 1184 | if (theme === "light" || theme === "dark") return theme; |
| 1185 | const legacyMode = legacyThemeMode(theme); |
| 1186 | if (legacyMode) return legacyMode; |
| 1187 | return getThemeDefinition(theme)?.appearance ?? null; |
| 1188 | } |
| 1189 | |
| 1190 | export function themeIdFromName(name: string): string { |
| 1191 | const normalized = name |
| 1192 | .trim() |
| 1193 | .toLowerCase() |
| 1194 | .replace(/[^a-z0-9]+/g, "-") |
| 1195 | .replace(/^-+|-+$/g, "") |
| 1196 | .slice(0, 48); |
| 1197 | return normalized || "custom-theme"; |
| 1198 | } |
| 1199 | |
| 1200 | export class ThemeLibraryStorageError extends Schema.TaggedError<ThemeLibraryStorageError>()( |
| 1201 | "ThemeLibraryStorageError", |
| 1202 | { |
| 1203 | storageKey: Schema.String, |
| 1204 | operation: Schema.Literals(["read", "write"]), |
| 1205 | reason: Schema.Literals(["malformed", "storage-unavailable"]), |
| 1206 | cause: Schema.optional(Schema.Defect()), |
| 1207 | }, |
| 1208 | ) { |
| 1209 | override get message(): string { |
| 1210 | const direction = this.operation === "read" ? "from" : "to"; |
| 1211 | return `Failed to ${this.operation} the theme library ${direction} ${this.storageKey}.`; |
| 1212 | } |
| 1213 | } |
| 1214 | |
| 1215 | function saveCustomThemes( |
| 1216 | storedThemes: ReadonlyArray<unknown>, |
| 1217 | themes: ReadonlyArray<ThemeDefinition>, |
| 1218 | source: "local" | "sync" = "local", |
| 1219 | ): void { |
| 1220 | if (typeof window === "undefined") return; |
| 1221 | try { |
| 1222 | window.localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify(storedThemes)); |
| 1223 | customThemeLibrarySnapshot = { status: "ready", storedThemes, themes }; |
| 1224 | } catch (cause) { |
| 1225 | throw new ThemeLibraryStorageError({ |
| 1226 | storageKey: CUSTOM_THEMES_STORAGE_KEY, |
| 1227 | operation: "write", |
| 1228 | reason: "storage-unavailable", |
| 1229 | cause, |
| 1230 | }); |
| 1231 | } |
| 1232 | notifyCustomThemeListeners(); |
| 1233 | if (source === "local") for (const listener of customThemeEditListeners) listener(); |
| 1234 | } |
| 1235 | |
| 1236 | function requireWritableCustomThemeLibrary( |
| 1237 | snapshot: CustomThemeLibrarySnapshot, |
| 1238 | ): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> { |
| 1239 | if (snapshot.status === "unavailable") { |
| 1240 | throw new ThemeLibraryStorageError({ |
| 1241 | storageKey: CUSTOM_THEMES_STORAGE_KEY, |
| 1242 | operation: "read", |
| 1243 | reason: snapshot.reason, |
| 1244 | ...("cause" in snapshot ? { cause: snapshot.cause } : {}), |
| 1245 | }); |
| 1246 | } |
| 1247 | return snapshot; |
| 1248 | } |
| 1249 | |
| 1250 | function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> { |
| 1251 | return requireWritableCustomThemeLibrary(getCustomThemeLibrarySnapshot()); |
| 1252 | } |
| 1253 | |
| 1254 | function readWritableCustomThemeLibrary(): Extract< |
| 1255 | CustomThemeLibrarySnapshot, |
| 1256 | { status: "ready" } |
| 1257 | > { |
| 1258 | return requireWritableCustomThemeLibrary(readCustomThemeLibrarySnapshot()); |
| 1259 | } |
| 1260 | |
| 1261 | function storedThemeHasId(storedTheme: unknown, themeId: string): boolean { |
| 1262 | return isRecord(storedTheme) && storedTheme.id === themeId; |
| 1263 | } |
| 1264 | |
| 1265 | function storedThemeHasCollectionId(storedTheme: unknown, collectionId: string): boolean { |
| 1266 | return ( |
| 1267 | isRecord(storedTheme) && |
| 1268 | isRecord(storedTheme.collection) && |
| 1269 | storedTheme.collection.id === collectionId |
| 1270 | ); |
| 1271 | } |
| 1272 | |
| 1273 | export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition { |
| 1274 | if (RESERVED_THEME_IDS.has(theme.id)) { |
| 1275 | throw new Error(`The theme id "${theme.id}" is reserved.`); |
| 1276 | } |
| 1277 | const library = getWritableCustomThemeLibrary(); |
| 1278 | if ( |
| 1279 | BUILT_IN_THEME_DEFINITIONS.some((existing) => existing.id === theme.id) || |
| 1280 | library.storedThemes.some((storedTheme) => storedThemeHasId(storedTheme, theme.id)) |
| 1281 | ) { |
| 1282 | throw new Error(`A theme named "${theme.label}" is already installed.`); |
| 1283 | } |
| 1284 | const canonicalTheme = canonicalizeThemeDefinition(theme); |
| 1285 | const themes = [...library.themes, canonicalTheme]; |
| 1286 | saveCustomThemes([...library.storedThemes, canonicalTheme], themes); |
| 1287 | return canonicalTheme; |
| 1288 | } |
| 1289 | |
| 1290 | export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition { |
| 1291 | if (RESERVED_THEME_IDS.has(theme.id)) { |
| 1292 | throw new Error(`The theme id "${theme.id}" is reserved.`); |
| 1293 | } |
| 1294 | |
| 1295 | const library = getWritableCustomThemeLibrary(); |
| 1296 | const themes = library.themes; |
| 1297 | const themeIndex = themes.findIndex((existing) => existing.id === theme.id); |
| 1298 | if (themeIndex === -1) { |
| 1299 | throw new Error(`The theme "${theme.label}" is not installed.`); |
| 1300 | } |
| 1301 | |
| 1302 | const canonicalTheme = canonicalizeThemeDefinition(theme); |
| 1303 | const nextThemes = [...themes]; |
| 1304 | nextThemes[themeIndex] = canonicalTheme; |
| 1305 | |
| 1306 | const nextStoredThemes: unknown[] = []; |
| 1307 | let replaced = false; |
| 1308 | for (const storedTheme of library.storedThemes) { |
| 1309 | if (!storedThemeHasId(storedTheme, theme.id)) { |
| 1310 | nextStoredThemes.push(storedTheme); |
| 1311 | } else if (!replaced) { |
| 1312 | nextStoredThemes.push(canonicalTheme); |
| 1313 | replaced = true; |
| 1314 | } |
| 1315 | } |
| 1316 | saveCustomThemes(nextStoredThemes, nextThemes); |
| 1317 | return canonicalTheme; |
| 1318 | } |
| 1319 | |
| 1320 | export function replaceCustomThemeCollection( |
| 1321 | collectionId: string, |
| 1322 | themes: ReadonlyArray<ThemeDefinition>, |
| 1323 | options?: { expectedCollection?: ReadonlyArray<ThemeDefinition> }, |
| 1324 | ): ReadonlyArray<ThemeDefinition> { |
| 1325 | if (themes.length === 0) throw new Error("A theme collection cannot be empty."); |
| 1326 | |
| 1327 | const validated = themes.map((theme) => parseStoredTheme(theme)); |
| 1328 | if ( |
| 1329 | validated.some((theme) => theme === null || theme.collection?.id !== collectionId) || |
| 1330 | new Set(validated.map((theme) => theme?.id)).size !== validated.length |
| 1331 | ) { |
| 1332 | throw new Error("That theme collection is invalid."); |
| 1333 | } |
| 1334 | const replacement = validated as ThemeDefinition[]; |
| 1335 | const library = readWritableCustomThemeLibrary(); |
| 1336 | const current = library.themes; |
| 1337 | const currentCollection = current.filter((theme) => theme.collection?.id === collectionId); |
| 1338 | if ( |
| 1339 | options?.expectedCollection && |
| 1340 | JSON.stringify(currentCollection) !== JSON.stringify(options.expectedCollection) |
| 1341 | ) { |
| 1342 | throw new Error("Your installed themes changed while this package was downloading. Try again."); |
| 1343 | } |
| 1344 | const occupiedIds = new Set(BUILT_IN_THEME_DEFINITIONS.map((theme) => theme.id)); |
| 1345 | for (const storedTheme of library.storedThemes) { |
| 1346 | if ( |
| 1347 | !storedThemeHasCollectionId(storedTheme, collectionId) && |
| 1348 | isRecord(storedTheme) && |
| 1349 | typeof storedTheme.id === "string" |
| 1350 | ) { |
| 1351 | occupiedIds.add(storedTheme.id); |
| 1352 | } |
| 1353 | } |
| 1354 | const conflictingTheme = replacement.find( |
| 1355 | (theme) => RESERVED_THEME_IDS.has(theme.id) || occupiedIds.has(theme.id), |
| 1356 | ); |
| 1357 | if (conflictingTheme) { |
| 1358 | throw new Error(`A theme named "${conflictingTheme.label}" is already installed.`); |
| 1359 | } |
| 1360 | |
| 1361 | const nextStoredThemes: unknown[] = []; |
| 1362 | let insertedReplacement = false; |
| 1363 | for (const storedTheme of library.storedThemes) { |
| 1364 | if (!storedThemeHasCollectionId(storedTheme, collectionId)) { |
| 1365 | nextStoredThemes.push(storedTheme); |
| 1366 | } else if (!insertedReplacement) { |
| 1367 | nextStoredThemes.push(...replacement); |
| 1368 | insertedReplacement = true; |
| 1369 | } |
| 1370 | } |
| 1371 | if (!insertedReplacement) nextStoredThemes.push(...replacement); |
| 1372 | |
| 1373 | saveCustomThemes(nextStoredThemes, parseStoredThemes(nextStoredThemes)); |
| 1374 | return replacement; |
| 1375 | } |
| 1376 | |
| 1377 | export function removeCustomTheme(themeId: string): void { |
| 1378 | removeCustomThemes([themeId]); |
| 1379 | } |
| 1380 | |
| 1381 | export function removeCustomThemes(themeIds: ReadonlyArray<string>): void { |
| 1382 | const removedIds = new Set(themeIds); |
| 1383 | if (removedIds.size === 0) return; |
| 1384 | const library = getWritableCustomThemeLibrary(); |
| 1385 | const nextThemes = library.themes.filter((theme) => !removedIds.has(theme.id)); |
| 1386 | if (nextThemes.length === library.themes.length) return; |
| 1387 | saveCustomThemes( |
| 1388 | library.storedThemes.filter( |
| 1389 | (storedTheme) => |
| 1390 | !isRecord(storedTheme) || |
| 1391 | typeof storedTheme.id !== "string" || |
| 1392 | !removedIds.has(storedTheme.id), |
| 1393 | ), |
| 1394 | nextThemes, |
| 1395 | ); |
| 1396 | } |
| 1397 | |
| 1398 | function parseThemeColorOverrides(value: unknown): ThemeColorOverrides { |
| 1399 | if (!isRecord(value)) throw new Error("Theme colors must be objects."); |
| 1400 | |
| 1401 | const overrides: Partial<Record<ThemeColorRole, string>> = {}; |
| 1402 | for (const [role, color] of Object.entries(value)) { |
| 1403 | if (!THEME_COLOR_ROLE_SET.has(role)) { |
| 1404 | throw new Error(`"${role}" is not a supported theme color role.`); |
| 1405 | } |
| 1406 | const normalized = toCanonicalThemeColor(color); |
| 1407 | if (!normalized) { |
| 1408 | throw new Error( |
| 1409 | `The color for "${role}" must be a literal CSS color such as oklch(0.62 0.2 280).`, |
| 1410 | ); |
| 1411 | } |
| 1412 | overrides[role as ThemeColorRole] = normalized; |
| 1413 | } |
| 1414 | if (Object.keys(overrides).length === 0) { |
| 1415 | throw new Error("Add at least one color role to the theme file."); |
| 1416 | } |
| 1417 | return overrides; |
| 1418 | } |
| 1419 | |
| 1420 | export function parseThemeFile(value: unknown): ThemeDefinition { |
| 1421 | if (!isRecord(value)) { |
| 1422 | throw new Error("Theme files must contain a JSON object."); |
| 1423 | } |
| 1424 | if (value.version !== THEME_FILE_VERSION) { |
| 1425 | throw new Error(`This theme file uses an unsupported version. Expected ${THEME_FILE_VERSION}.`); |
| 1426 | } |
| 1427 | |
| 1428 | const name = value.name; |
| 1429 | const appearance = value.appearance; |
| 1430 | const rawColors = value.colors; |
| 1431 | if (!isThemeLabel(name)) throw new Error("Theme files need a name (48 characters or fewer)."); |
| 1432 | if (!isThemeAppearance(appearance)) { |
| 1433 | throw new Error('Theme files need an appearance of "light" or "dark".'); |
| 1434 | } |
| 1435 | if (!isRecord(rawColors)) throw new Error("Theme files need a colors object."); |
| 1436 | |
| 1437 | const id = value.id === undefined ? themeIdFromName(name) : value.id; |
| 1438 | if (!isThemeId(id)) { |
| 1439 | throw new Error("Theme ids may only contain lowercase letters, numbers, and hyphens."); |
| 1440 | } |
| 1441 | if (RESERVED_THEME_IDS.has(id)) { |
| 1442 | throw new Error(`The theme id "${id}" is reserved.`); |
| 1443 | } |
| 1444 | |
| 1445 | const overrides = parseThemeColorOverrides(rawColors); |
| 1446 | const collection = parseThemeCollection(value.collection); |
| 1447 | if (value.collection !== undefined && !collection) { |
| 1448 | throw new Error("Theme collections need a valid id and label."); |
| 1449 | } |
| 1450 | |
| 1451 | const fallback = getDefaultThemeColors(appearance); |
| 1452 | const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {}; |
| 1453 | if (value.variants !== undefined) { |
| 1454 | if (!isRecord(value.variants)) throw new Error("Theme variants must be an object."); |
| 1455 | for (const [variantAppearance, variantColors] of Object.entries(value.variants)) { |
| 1456 | if (!isThemeAppearance(variantAppearance)) { |
| 1457 | throw new Error('Theme variants may only be named "light" or "dark".'); |
| 1458 | } |
| 1459 | if (variantAppearance === appearance) { |
| 1460 | throw new Error(`Theme variants must not repeat the base appearance "${appearance}".`); |
| 1461 | } |
| 1462 | const variantFallback = getDefaultThemeColors(variantAppearance); |
| 1463 | variants[variantAppearance] = { |
| 1464 | ...variantFallback, |
| 1465 | ...parseThemeColorOverrides(variantColors), |
| 1466 | }; |
| 1467 | } |
| 1468 | } |
| 1469 | |
| 1470 | return { |
| 1471 | id, |
| 1472 | label: name.trim(), |
| 1473 | appearance, |
| 1474 | colors: { ...fallback, ...overrides }, |
| 1475 | ...(Object.keys(variants).length > 0 ? { variants } : {}), |
| 1476 | ...(collection ? { collection } : {}), |
| 1477 | ...(value.managed === true ? { managed: true } : {}), |
| 1478 | }; |
| 1479 | } |
| 1480 | |
| 1481 | export function serializeThemeFile(theme: ThemeDefinition): string { |
| 1482 | const canonicalTheme = canonicalizeThemeDefinition(theme); |
| 1483 | const file: ThemeFile = { |
| 1484 | version: THEME_FILE_VERSION, |
| 1485 | id: canonicalTheme.id, |
| 1486 | name: canonicalTheme.label, |
| 1487 | appearance: canonicalTheme.appearance, |
| 1488 | colors: canonicalTheme.colors, |
| 1489 | ...(canonicalTheme.variants ? { variants: canonicalTheme.variants } : {}), |
| 1490 | ...(canonicalTheme.collection ? { collection: canonicalTheme.collection } : {}), |
| 1491 | ...(canonicalTheme.managed ? { managed: true } : {}), |
| 1492 | }; |
| 1493 | return `${JSON.stringify(file, null, 2)}\n`; |
| 1494 | } |
| 1495 | |
| 1496 | const APP_THEME_VARIABLES: Readonly<Record<ThemeColorRole, string>> = { |
| 1497 | canvas: "--app-theme-canvas", |
| 1498 | chrome: "--app-theme-chrome", |
| 1499 | toolbar: "--app-theme-toolbar", |
| 1500 | toolbarForeground: "--app-theme-toolbar-foreground", |
| 1501 | toolbarBorder: "--app-theme-toolbar-border", |
| 1502 | toolbarControl: "--app-theme-toolbar-control", |
| 1503 | toolbarControlForeground: "--app-theme-toolbar-control-foreground", |
| 1504 | toolbarControlHover: "--app-theme-toolbar-control-hover", |
| 1505 | surface: "--app-theme-surface", |
| 1506 | surfaceRaised: "--app-theme-surface-raised", |
| 1507 | surfaceOverlay: "--app-theme-surface-overlay", |
| 1508 | text: "--app-theme-text", |
| 1509 | textMuted: "--app-theme-text-muted", |
| 1510 | border: "--app-theme-border", |
| 1511 | input: "--app-theme-input", |
| 1512 | focus: "--app-theme-focus", |
| 1513 | accent: "--app-theme-accent", |
| 1514 | accentForeground: "--app-theme-accent-foreground", |
| 1515 | secondary: "--app-theme-secondary", |
| 1516 | secondaryForeground: "--app-theme-secondary-foreground", |
| 1517 | muted: "--app-theme-muted", |
| 1518 | mutedForeground: "--app-theme-muted-foreground", |
| 1519 | placeholder: "--app-theme-placeholder", |
| 1520 | secondaryLabel: "--app-theme-secondary-label", |
| 1521 | iconMuted: "--app-theme-icon-muted", |
| 1522 | error: "--app-theme-error", |
| 1523 | errorForeground: "--app-theme-error-foreground", |
| 1524 | errorSurface: "--app-theme-error-surface", |
| 1525 | warning: "--app-theme-warning", |
| 1526 | warningForeground: "--app-theme-warning-foreground", |
| 1527 | warningSurface: "--app-theme-warning-surface", |
| 1528 | update: "--app-theme-update", |
| 1529 | updateForeground: "--app-theme-update-foreground", |
| 1530 | updateSurface: "--app-theme-update-surface", |
| 1531 | accentSurface: "--app-theme-accent-surface", |
| 1532 | accentSurfaceForeground: "--app-theme-accent-surface-foreground", |
| 1533 | messageSurface: "--app-theme-message-surface", |
| 1534 | messageForeground: "--app-theme-message-foreground", |
| 1535 | messageAction: "--app-theme-message-action", |
| 1536 | messageActionForeground: "--app-theme-message-action-foreground", |
| 1537 | messageActionHover: "--app-theme-message-action-hover", |
| 1538 | codeBackground: "--app-theme-code-background", |
| 1539 | codeForeground: "--app-theme-code-foreground", |
| 1540 | searchMatchBackground: "--app-theme-search-match-background", |
| 1541 | searchMatchForeground: "--app-theme-search-match-foreground", |
| 1542 | searchMatchActiveBackground: "--app-theme-search-match-active-background", |
| 1543 | searchMatchActiveForeground: "--app-theme-search-match-active-foreground", |
| 1544 | sidebar: "--app-theme-sidebar", |
| 1545 | sidebarForeground: "--app-theme-sidebar-foreground", |
| 1546 | sidebarMutedForeground: "--app-theme-sidebar-muted-foreground", |
| 1547 | sidebarControlSurface: "--app-theme-sidebar-control-surface", |
| 1548 | sidebarRowHover: "--app-theme-sidebar-row-hover", |
| 1549 | sidebarRowActive: "--app-theme-sidebar-row-active", |
| 1550 | sidebarRowSelected: "--app-theme-sidebar-row-selected", |
| 1551 | sidebarBorder: "--app-theme-sidebar-border", |
| 1552 | terminalBackground: "--app-theme-terminal-background", |
| 1553 | terminalForeground: "--app-theme-terminal-foreground", |
| 1554 | terminalCursor: "--app-theme-terminal-cursor", |
| 1555 | terminalSelection: "--app-theme-terminal-selection-background", |
| 1556 | terminalScrollbar: "--app-theme-terminal-scrollbar", |
| 1557 | terminalScrollbarHover: "--app-theme-terminal-scrollbar-hover", |
| 1558 | }; |
| 1559 | |
| 1560 | export function getThemeColorVariable(role: ThemeColorRole): string { |
| 1561 | return APP_THEME_VARIABLES[role]; |
| 1562 | } |
| 1563 | |
| 1564 | /** Marks the document as wearing an unsaved draft rather than a stored theme. */ |
| 1565 | export const THEME_PREVIEW_ID = "__preview"; |
| 1566 | |
| 1567 | /** |
| 1568 | * Paint a draft palette onto the live app without installing it, so the editor |
| 1569 | * can be judged against the real interface instead of a miniature. Callers |
| 1570 | * restore the stored theme (refreshTheme) when the draft goes away. |
| 1571 | */ |
| 1572 | export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void { |
| 1573 | if (typeof document === "undefined") return; |
| 1574 | const root = document.documentElement; |
| 1575 | if (!root?.style) return; |
| 1576 | |
| 1577 | // Drafts become user-controlled themes when saved, so their preview keeps |
| 1578 | // the fixed stage artwork hidden even when it was seeded from a built-in. |
| 1579 | setThemePreviewSidebarArtwork(false); |
| 1580 | root.dataset.themeId = THEME_PREVIEW_ID; |
| 1581 | root.classList.toggle("dark", appearance === "dark"); |
| 1582 | for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) { |
| 1583 | // A half-typed hex keeps the last good value instead of blanking the role. |
| 1584 | if (isThemeColor(value)) root.style.setProperty(APP_THEME_VARIABLES[role], value); |
| 1585 | } |
| 1586 | } |
| 1587 | |
| 1588 | export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppearance): void { |
| 1589 | if (typeof document === "undefined") return; |
| 1590 | |
| 1591 | const root = document.documentElement; |
| 1592 | if (!root?.style) return; |
| 1593 | |
| 1594 | setThemePreviewSidebarArtwork(null); |
| 1595 | const palette = getThemeDefinition(theme); |
| 1596 | |
| 1597 | if (palette) { |
| 1598 | root.dataset.themeId = palette.id; |
| 1599 | const mode = appearance ?? legacyThemeMode(theme) ?? palette.appearance; |
| 1600 | const colors = getThemeColorsForMode(palette, mode) ?? palette.colors; |
| 1601 | for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) { |
| 1602 | root.style.setProperty(APP_THEME_VARIABLES[role], value); |
| 1603 | } |
| 1604 | return; |
| 1605 | } |
| 1606 | |
| 1607 | delete root.dataset.themeId; |
| 1608 | for (const variable of Object.values(APP_THEME_VARIABLES)) { |
| 1609 | root.style.removeProperty(variable); |
| 1610 | } |
| 1611 | } |
| 1612 | |
| 1613 | export function resolveThemeAppearance( |
| 1614 | theme: ThemePreference, |
| 1615 | systemDark: boolean, |
| 1616 | followSystem?: boolean, |
| 1617 | appearanceMode?: ThemePreferenceMode, |
| 1618 | halves?: ThemeHalves | null, |
| 1619 | ): "light" | "dark" { |
| 1620 | const systemAppearance = systemDark ? "dark" : "light"; |
| 1621 | const mode = appearanceMode ?? ((followSystem ?? theme === "system") ? "system" : null); |
| 1622 | if (mode === "system") { |
| 1623 | // A configured half guarantees the appearance is renderable even when the |
| 1624 | // base theme lacks that mode. |
| 1625 | if (halves?.[systemAppearance]) return systemAppearance; |
| 1626 | const definition = getThemeDefinition(theme); |
| 1627 | return definition && getThemeColorsForMode(definition, systemAppearance) === null |
| 1628 | ? definition.appearance |
| 1629 | : systemAppearance; |
| 1630 | } |
| 1631 | if (mode === "light" || mode === "dark") { |
| 1632 | if (halves?.[mode]) return mode; |
| 1633 | const definition = getThemeDefinition(theme); |
| 1634 | return definition && getThemeColorsForMode(definition, mode) === null |
| 1635 | ? definition.appearance |
| 1636 | : mode; |
| 1637 | } |
| 1638 | return getThemePreferenceMode(theme) ?? "light"; |
| 1639 | } |
| 1640 | |
| 1641 | export function resolveDesktopTheme( |
| 1642 | theme: ThemePreference, |
| 1643 | followSystem?: boolean, |
| 1644 | appearanceMode?: ThemePreferenceMode, |
| 1645 | halves?: ThemeHalves | null, |
| 1646 | ): "light" | "dark" | "system" { |
| 1647 | const mode = appearanceMode ?? ((followSystem ?? theme === "system") ? "system" : null); |
| 1648 | if (mode === "system") { |
| 1649 | const definition = getThemeDefinition(theme); |
| 1650 | // A configured half fills in an appearance the base theme cannot render. |
| 1651 | const hasLightMode = |
| 1652 | halves?.light !== undefined || |
| 1653 | (definition !== null && getThemeColorsForMode(definition, "light") !== null); |
| 1654 | const hasDarkMode = |
| 1655 | halves?.dark !== undefined || |
| 1656 | (definition !== null && getThemeColorsForMode(definition, "dark") !== null); |
| 1657 | return definition && (!hasLightMode || !hasDarkMode) ? definition.appearance : "system"; |
| 1658 | } |
| 1659 | if (mode === "light" || mode === "dark") { |
| 1660 | if (halves?.[mode]) return mode; |
| 1661 | const definition = getThemeDefinition(theme); |
| 1662 | return definition && getThemeColorsForMode(definition, mode) === null |
| 1663 | ? definition.appearance |
| 1664 | : mode; |
| 1665 | } |
| 1666 | return getThemePreferenceMode(theme) ?? "system"; |
| 1667 | } |
| 1668 | |
| 1669 | export function isKnownThemePreference(theme: string): boolean { |
| 1670 | if (theme === "light" || theme === "dark" || theme === "system") return true; |
| 1671 | return getThemeDefinition(theme) !== null; |
| 1672 | } |
| 1673 | |
| 1674 | /** |
| 1675 | * An automatic-mode mix: a different theme per resolved appearance. Halves |
| 1676 | * only name real themes that can render their half; anything else is dropped |
| 1677 | * so a stale mix degrades to the base preference. |
| 1678 | */ |
| 1679 | export type ThemeHalves = Readonly<{ light?: string; dark?: string }>; |
| 1680 | |
| 1681 | export function parseThemeHalves(raw: string | null): ThemeHalves | null { |
| 1682 | if (!raw) return null; |
| 1683 | try { |
| 1684 | const value: unknown = JSON.parse(raw); |
| 1685 | if (!isRecord(value)) return null; |
| 1686 | const halves: { light?: string; dark?: string } = {}; |
| 1687 | for (const appearance of ["light", "dark"] as const) { |
| 1688 | const themeId = value[appearance]; |
| 1689 | if (typeof themeId !== "string") continue; |
| 1690 | const definition = getThemeDefinition(themeId); |
| 1691 | if (definition && getThemeColorsForMode(definition, appearance) !== null) { |
| 1692 | // Store the definition's id so legacy aliases resolve to the same |
| 1693 | // value the runtime applies to the document. |
| 1694 | halves[appearance] = definition.id; |
| 1695 | } |
| 1696 | } |
| 1697 | return halves.light !== undefined || halves.dark !== undefined ? halves : null; |
| 1698 | } catch { |
| 1699 | return null; |
| 1700 | } |
| 1701 | } |
| 1702 | |
| 1703 | /** The theme that should render the given appearance under a mix, if any. */ |
| 1704 | export function resolveThemeHalf( |
| 1705 | theme: ThemePreference, |
| 1706 | halves: ThemeHalves | null, |
| 1707 | appearance: ThemeAppearance, |
| 1708 | ): ThemePreference { |
| 1709 | return halves?.[appearance] ?? theme; |
| 1710 | } |