1import type { UiThemePreferences } from "@t3tools/contracts";
2import * as Equal from "effect/Equal";
3import * as Schema from "effect/Schema";
4import "culori/css";
5import { converter, parse } from "culori/fn";
6import {
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
24export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES };
25export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants };
26
27export const T3_CHAT_THEME_ID = "t3-chat" as const;
28const GROVE_THEME_ID = "grove" as const;
29export const OCEAN_THEME_ID = "ocean" as const;
30const EMBER_THEME_ID = "ember" as const;
31const IRIS_THEME_ID = "iris" as const;
32export const THEME_FILE_VERSION = 1 as const;
33export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1";
34export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system";
35export const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode";
36export const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1";
37
38const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark";
39
40export const ThemePreference = Schema.String;
41export type ThemePreference = typeof ThemePreference.Type;
42
43const THEME_COLOR_ROLE_SET: ReadonlySet<string> = new Set(THEME_COLOR_ROLES);
44export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string>>>;
45export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>;
46export type ThemePreferenceMode = ThemeAppearance | "system";
47export type ThemeCollection = Readonly<{ id: string; label: string }>;
48export 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 */
68let environmentThemeDefinitions: ReadonlyArray<ThemeDefinition> = [];
69
70const customThemeListeners = new Set<() => void>();
71type 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
80let customThemeLibrarySnapshot: CustomThemeLibrarySnapshot | null = null;
81const themePreviewListeners = new Set<() => void>();
82let themePreviewSidebarArtwork: boolean | null = null;
83
84export function getThemePreviewSidebarArtwork(): boolean | null {
85 return themePreviewSidebarArtwork;
86}
87
88export function subscribeToThemePreview(listener: () => void): () => void {
89 themePreviewListeners.add(listener);
90 return () => themePreviewListeners.delete(listener);
91}
92
93function setThemePreviewSidebarArtwork(next: boolean | null): void {
94 if (themePreviewSidebarArtwork === next) return;
95 themePreviewSidebarArtwork = next;
96 for (const listener of themePreviewListeners) listener();
97}
98
99function isRecord(value: unknown): value is Record<string, unknown> {
100 return typeof value === "object" && value !== null && !Array.isArray(value);
101}
102
103function isThemeAppearance(value: unknown): value is ThemeAppearance {
104 return value === "light" || value === "dark";
105}
106
107export function isThemeColor(value: unknown): value is string {
108 return typeof value === "string" && toCanonicalThemeColor(value) !== null;
109}
110
111function isThemeId(value: unknown): value is string {
112 return typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value);
113}
114
115function isThemeLabel(value: unknown): value is string {
116 return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48;
117}
118
119function 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 */
134export 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
147function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null {
148 if (!isRecord(value)) return null;
149 return { ...getDefaultThemeColors(appearance), ...lenientThemeColorOverrides(value) };
150}
151
152function 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
172function 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
193function 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
204function 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
228function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {
229 if (customThemeLibrarySnapshot === null) {
230 customThemeLibrarySnapshot = readCustomThemeLibrarySnapshot();
231 }
232 return customThemeLibrarySnapshot;
233}
234
235function notifyCustomThemeListeners() {
236 for (const listener of customThemeListeners) listener();
237}
238
239export function invalidateCustomThemes() {
240 customThemeLibrarySnapshot = null;
241 notifyCustomThemeListeners();
242}
243
244const EMPTY_CUSTOM_THEMES: ReadonlyArray<ThemeDefinition> = [];
245
246const customThemeEditListeners = new Set<() => void>();
247export function subscribeToCustomThemeEdits(listener: () => void) {
248 customThemeEditListeners.add(listener);
249 return () => {
250 customThemeEditListeners.delete(listener);
251 };
252}
253
254export 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
276export 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
291export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {
292 const snapshot = getCustomThemeLibrarySnapshot();
293 return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES;
294}
295
296export 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 */
305export 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. */
313export function isReservedThemeId(themeId: string): boolean {
314 return RESERVED_THEME_IDS.has(themeId);
315}
316
317export function getStoredCustomThemeCollection(
318 collectionId: string,
319): ReadonlyArray<ThemeDefinition> {
320 return readWritableCustomThemeLibrary().themes.filter(
321 (theme) => theme.collection?.id === collectionId,
322 );
323}
324
325export 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.
346const 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
354function 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 */
363export function canonicalThemePreference(theme: string): string {
364 return theme === LEGACY_T3_CHAT_DARK_THEME_ID ? theme : normalizeThemeId(theme);
365}
366
367function 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.
373function 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 */
383export 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
390type ThemeRgbColor = {
391 r: number;
392 g: number;
393 b: number;
394};
395
396type ThemeOklch = { L: number; C: number; h: number };
397type ParsedThemeColor = { color: ThemeOklch; alpha: number };
398
399let standardLightThemeColors: ThemeColors | undefined;
400let standardDarkThemeColors: ThemeColors | undefined;
401
402const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 };
403const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 };
404const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 };
405const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 };
406
407const convertToOklch = converter("oklch");
408
409function 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
432function 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
437function 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 */
447export 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. */
453export 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
466function parseThemeRgbColor(value: string, fallback: ThemeRgbColor): ThemeRgbColor {
467 const parsed = parseThemeColor(value);
468 return parsed ? themeOklchToRgb(parsed.color) : fallback;
469}
470
471function 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
481function themeRgbToThemeColor(color: ThemeRgbColor): string {
482 return formatOklchThemeColor(themeRgbToOklch(color));
483}
484
485function 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
499function 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
516function 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
528function 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
540function 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
545function 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
550function 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
563function 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. */
578function 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. */
603function 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
612function themeOklchToThemeColor(color: ThemeOklch): string {
613 return formatOklchThemeColor(mapThemeOklchToSrgbGamut(color));
614}
615
616/** Binary-search the lightness that reaches the contrast target against a background. */
617function 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 */
648const 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 */
668function 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 */
715export 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
860function 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
868function 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
881function 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.
913const STANDARD_LIGHT_MUTED_CONTRAST = 4.705;
914const STANDARD_DARK_MUTED_CONTRAST = 5.082;
915
916function 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. */
928export 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 */
938export 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
1136const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = BUILT_IN_THEMES;
1137
1138export 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. */
1151export 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 */
1165export function singleAppearanceOf(theme: ThemeDefinition): ThemeAppearance | null {
1166 const modes = getThemeModes(theme);
1167 return modes.length === 1 ? modes[0]! : null;
1168}
1169
1170export 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
1178export function getThemeModes(theme: ThemeDefinition): ReadonlyArray<ThemeAppearance> {
1179 return (["light", "dark"] as const).filter((mode) => getThemeColorsForMode(theme, mode) !== null);
1180}
1181
1182export 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
1190export 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
1200export 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
1215function 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
1236function 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
1250function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> {
1251 return requireWritableCustomThemeLibrary(getCustomThemeLibrarySnapshot());
1252}
1253
1254function readWritableCustomThemeLibrary(): Extract<
1255 CustomThemeLibrarySnapshot,
1256 { status: "ready" }
1257> {
1258 return requireWritableCustomThemeLibrary(readCustomThemeLibrarySnapshot());
1259}
1260
1261function storedThemeHasId(storedTheme: unknown, themeId: string): boolean {
1262 return isRecord(storedTheme) && storedTheme.id === themeId;
1263}
1264
1265function storedThemeHasCollectionId(storedTheme: unknown, collectionId: string): boolean {
1266 return (
1267 isRecord(storedTheme) &&
1268 isRecord(storedTheme.collection) &&
1269 storedTheme.collection.id === collectionId
1270 );
1271}
1272
1273export 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
1290export 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
1320export 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
1377export function removeCustomTheme(themeId: string): void {
1378 removeCustomThemes([themeId]);
1379}
1380
1381export 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
1398function 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
1420export 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
1481export 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
1496const 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
1560export 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. */
1565export 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 */
1572export 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
1588export 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
1613export 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
1641export 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
1669export 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 */
1679export type ThemeHalves = Readonly<{ light?: string; dark?: string }>;
1680
1681export 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. */
1704export function resolveThemeHalf(
1705 theme: ThemePreference,
1706 halves: ThemeHalves | null,
1707 appearance: ThemeAppearance,
1708): ThemePreference {
1709 return halves?.[appearance] ?? theme;
1710}