1import {
2 createVividThemeColors,
3 getStandardThemeColors,
4 getThemeModes,
5 isReservedThemeId,
6 parseThemeFile,
7 themeColorToHex,
8 themeIdFromName,
9 THEME_FILE_VERSION,
10 type ThemeAppearance,
11 type ThemeColorRole,
12 type ThemeDefinition,
13} from "./themePalette";
14
15/**
16 * Best-effort import of a VS Code color theme (`*-color-theme.json`).
17 *
18 * VS Code themes describe editor chrome, not an app palette: they carry a few
19 * hundred workbench keys, leave most of them unset, and freely use 8-digit
20 * hex with alpha for overlays. The conversion derives a complete palette from
21 * a visible accent and the editor background, then layers usable workbench
22 * colors on top. Foregrounds must remain readable, while authored focus and
23 * control candidates must remain distinct from adjacent surfaces and states.
24 */
25
26type VsCodeRgba = { r: number; g: number; b: number; a: number };
27type VsCodeRgb = { r: number; g: number; b: number };
28
29function isRecord(value: unknown): value is Record<string, unknown> {
30 return typeof value === "object" && value !== null && !Array.isArray(value);
31}
32
33/** sRGB transfer function, and its inverse, shared by the wide-gamut path. */
34function decodeGamma(value: number): number {
35 return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
36}
37
38function encodeGamma(value: number): number {
39 const clamped = Math.max(0, Math.min(1, value));
40 return clamped <= 0.0031308 ? clamped * 12.92 : 1.055 * clamped ** (1 / 2.4) - 0.055;
41}
42
43/**
44 * `color(display-p3 r g b / a)` shows up in themes authored for wide-gamut
45 * displays. Out-of-gamut colors clip to the sRGB edge, which is what a browser
46 * on an sRGB screen shows anyway.
47 */
48function parseColorFunction(value: string): VsCodeRgba | null {
49 const match = /^color\(\s*(display-p3|srgb)\s+([^)]+)\)$/i.exec(value);
50 if (!match) return null;
51 const [space, body] = [match[1]!.toLowerCase(), match[2]!];
52 const [channelPart, alphaPart] = body.split("/");
53 const channels = channelPart!
54 .trim()
55 .split(/\s+/)
56 .map((part) => (part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part)));
57 if (channels.length !== 3 || channels.some((channel) => !Number.isFinite(channel))) return null;
58 const alphaRaw = alphaPart?.trim();
59 const alpha =
60 alphaRaw === undefined
61 ? 1
62 : alphaRaw.endsWith("%")
63 ? Number.parseFloat(alphaRaw) / 100
64 : Number.parseFloat(alphaRaw);
65 if (!Number.isFinite(alpha)) return null;
66
67 const [red, green, blue] = channels as [number, number, number];
68 if (space === "srgb") {
69 return { r: red * 255, g: green * 255, b: blue * 255, a: Math.max(0, Math.min(1, alpha)) };
70 }
71 const [linearRed, linearGreen, linearBlue] = [red, green, blue].map(decodeGamma) as [
72 number,
73 number,
74 number,
75 ];
76 // Display P3 linear -> sRGB linear.
77 const srgb = [
78 1.2249401762805 * linearRed - 0.2249401762805 * linearGreen,
79 -0.042056961239 * linearRed + 1.042056961239 * linearGreen,
80 -0.0196375547643 * linearRed - 0.0786360655012 * linearGreen + 1.0982736202656 * linearBlue,
81 ].map((channel) => encodeGamma(channel) * 255) as [number, number, number];
82 return { r: srgb[0], g: srgb[1], b: srgb[2], a: Math.max(0, Math.min(1, alpha)) };
83}
84
85/** VS Code accepts #RGB, #RGBA, #RRGGBB, and #RRGGBBAA; some themes also use
86 * CSS color() notation for wide-gamut palettes. */
87function parseVsCodeColor(value: unknown): VsCodeRgba | null {
88 if (typeof value !== "string") return null;
89 const trimmed = value.trim();
90 if (trimmed.startsWith("color(")) return parseColorFunction(trimmed);
91 const hex = trimmed.replace(/^#/, "");
92 if (!/^(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)) return null;
93 const expand = (part: string) =>
94 part.length === 1 ? Number.parseInt(part + part, 16) : Number.parseInt(part, 16);
95 if (hex.length <= 4) {
96 return {
97 r: expand(hex[0]!),
98 g: expand(hex[1]!),
99 b: expand(hex[2]!),
100 a: hex.length === 4 ? expand(hex[3]!) / 255 : 1,
101 };
102 }
103 return {
104 r: Number.parseInt(hex.slice(0, 2), 16),
105 g: Number.parseInt(hex.slice(2, 4), 16),
106 b: Number.parseInt(hex.slice(4, 6), 16),
107 a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1,
108 };
109}
110
111function toHex(color: VsCodeRgb): string {
112 const channel = (value: number) =>
113 Math.max(0, Math.min(255, Math.round(value)))
114 .toString(16)
115 .padStart(2, "0");
116 return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
117}
118
119/** Overlays are semi-transparent in VS Code; our roles are opaque, so they are
120 * composited onto whatever surface they sit on. */
121function flattenOver(color: VsCodeRgba, base: VsCodeRgb): string {
122 if (color.a >= 1) return toHex(color);
123 return toHex({
124 r: color.r * color.a + base.r * (1 - color.a),
125 g: color.g * color.a + base.g * (1 - color.a),
126 b: color.b * color.a + base.b * (1 - color.a),
127 });
128}
129
130function relativeLuminance(color: VsCodeRgb): number {
131 const channel = (value: number) => {
132 const ratio = value / 255;
133 return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
134 };
135 return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
136}
137
138function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number {
139 const a = relativeLuminance(first);
140 const b = relativeLuminance(second);
141 return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
142}
143
144function hexToRgb(value: string): VsCodeRgb {
145 return parseVsCodeColor(themeColorToHex(value) ?? value) ?? { r: 0, g: 0, b: 0, a: 1 };
146}
147
148/**
149 * A VS Code theme is recognised by its workbench colors: the keys are dotted
150 * paths (`editor.background`), which our own files never use.
151 */
152export function isVsCodeThemeFile(value: unknown): boolean {
153 if (!isRecord(value)) return false;
154 if (value.version === THEME_FILE_VERSION) return false;
155 const hasWorkbenchColors =
156 isRecord(value.colors) && Object.keys(value.colors).some((key) => key.includes("."));
157 return hasWorkbenchColors || Array.isArray(value.tokenColors);
158}
159
160function resolveAppearance(value: Record<string, unknown>, canvas: VsCodeRgb): ThemeAppearance {
161 const type = typeof value.type === "string" ? value.type.toLowerCase() : null;
162 if (type === "light" || type === "hc-light") return "light";
163 if (type === "dark" || type === "hc-black") return "dark";
164 // Unlabelled themes (and the odd custom `type`) follow the editor surface.
165 return relativeLuminance(canvas) < 0.179 ? "dark" : "light";
166}
167
168/** Extension `name` fields are often package slugs; read them as words. */
169export function humanizeThemeName(raw: string): string {
170 const trimmed = raw.trim();
171 if (/\s/.test(trimmed) || !/[-_.]/.test(trimmed)) return trimmed;
172 return trimmed
173 .split(/[-_.]+/)
174 .filter(Boolean)
175 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
176 .join(" ");
177}
178
179function resolveName(value: Record<string, unknown>): string {
180 // Judge candidates by their humanized form: a displayName of "---"
181 // humanizes to nothing and must fall through to the name.
182 for (const candidate of [value.displayName, value.name]) {
183 if (typeof candidate !== "string") continue;
184 const humanized = humanizeThemeName(candidate);
185 if (humanized.length > 0) return humanized.slice(0, 48);
186 }
187 return "VS Code theme";
188}
189
190export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
191 if (!isRecord(value)) throw new Error("Theme files must contain a JSON object.");
192 const colors = isRecord(value.colors) ? value.colors : {};
193
194 /** First key that carries a usable color, in priority order. */
195 const pick = (...keys: ReadonlyArray<string>): VsCodeRgba | null => {
196 for (const key of keys) {
197 const parsed = parseVsCodeColor(colors[key]);
198 if (parsed) return parsed;
199 }
200 return null;
201 };
202 const solidOver = (base: VsCodeRgb, ...keys: ReadonlyArray<string>): string | null => {
203 const parsed = pick(...keys);
204 return parsed ? flattenOver(parsed, base) : null;
205 };
206
207 const canvasColor = pick("editor.background", "editorPane.background");
208 if (!canvasColor) {
209 throw new Error(
210 'That VS Code theme has no "editor.background" color, so there is nothing to build a palette from.',
211 );
212 }
213 const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b };
214 const appearance = resolveAppearance(value, canvas);
215
216 const canvasHex = toHex(canvas);
217 const raisedSurfaceCandidateHex = solidOver(
218 canvas,
219 "editorWidget.background",
220 "dropdown.background",
221 );
222
223 /** Accent and control candidates must clear this small separation floor
224 * from every adjacent surface or state checked by the importer. */
225 const standsApart = (first: string, second: string) =>
226 contrastRatio(hexToRgb(first), hexToRgb(second)) >= 1.1;
227
228 let accentColor: VsCodeRgba | null = null;
229 let accentHex: string | null = null;
230 for (const key of [
231 "focusBorder",
232 "button.background",
233 "textLink.foreground",
234 "activityBarBadge.background",
235 "progressBar.background",
236 "badge.background",
237 ]) {
238 const candidate = parseVsCodeColor(colors[key]);
239 if (!candidate) continue;
240 const candidateHex = flattenOver(candidate, canvas);
241 if (
242 !standsApart(candidateHex, canvasHex) ||
243 (raisedSurfaceCandidateHex !== null && !standsApart(candidateHex, raisedSurfaceCandidateHex))
244 )
245 continue;
246 accentColor = candidate;
247 accentHex = candidateHex;
248 break;
249 }
250 if (!accentColor || !accentHex) {
251 const standardAccentHex = themeColorToHex(getStandardThemeColors(appearance).accent)!;
252 accentHex =
253 [standardAccentHex, "#ffffff", "#000000"].find(
254 (candidate) =>
255 standsApart(candidate, canvasHex) &&
256 (raisedSurfaceCandidateHex === null || standsApart(candidate, raisedSurfaceCandidateHex)),
257 ) ?? standardAccentHex;
258 accentColor = parseVsCodeColor(accentHex)!;
259 }
260
261 // The derived palette is the floor: every role starts contrast-solved, then
262 // the theme's own workbench colors replace what it actually specified. The
263 // floor derives from a muted accent -- the vivid engine carries the accent
264 // hue into every surface, which washes an imported neutral palette (a gray
265 // theme with a blue focusBorder would get blue code and text surfaces).
266 const mutedAccentHex = flattenOver(
267 { r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 },
268 canvas,
269 );
270 const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex);
271 const sidebarHex =
272 solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar;
273 const sidebar = hexToRgb(sidebarHex);
274 const terminalHex =
275 solidOver(canvas, "terminal.background", "panel.background") ?? derived.terminalBackground;
276 const terminal = hexToRgb(terminalHex);
277
278 /** Foregrounds only win when they stay readable on the surface they land on;
279 * a theme tuned for its own chrome can be unreadable on ours. */
280 const readableOn = (
281 surface: string,
282 fallback: string,
283 ...keys: ReadonlyArray<string>
284 ): string => {
285 const surfaceRgb = hexToRgb(surface);
286 const isReadable = (candidate: string) => contrastRatio(hexToRgb(candidate), surfaceRgb) >= 4.5;
287 const specified = solidOver(surfaceRgb, ...keys);
288 if (specified && isReadable(specified)) return specified;
289 // The derived fallback was solved against the derived surface. When the
290 // file replaced that surface (a light sideBar in a dark theme, say), the
291 // fallback has to clear the bar there too, or the text falls back to the
292 // readable end of the greyscale for the surface actually in play.
293 if (isReadable(fallback)) return fallback;
294 return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000";
295 };
296
297 const surfaceRaisedHex = raisedSurfaceCandidateHex ?? derived.surfaceRaised;
298 // The checked switch track maps to messageAction, so resolve it before
299 // choosing the input role used by the unchecked track.
300 const buttonHex = solidOver(canvas, "button.background");
301 const actionHex =
302 buttonHex && standsApart(buttonHex, canvasHex) && standsApart(buttonHex, surfaceRaisedHex)
303 ? buttonHex
304 : accentHex;
305 const inputCandidates = [
306 derived.input,
307 derived.surfaceRaised,
308 getStandardThemeColors(appearance).input,
309 "#000000",
310 "#ffffff",
311 "#808080",
312 ];
313 let inputHex =
314 inputCandidates.find(
315 (candidate) => standsApart(candidate, canvasHex) && standsApart(candidate, actionHex),
316 ) ?? "#808080";
317 for (const key of ["input.background", "input.border"]) {
318 const candidate = solidOver(canvas, key);
319 if (candidate && standsApart(candidate, canvasHex) && standsApart(candidate, actionHex)) {
320 inputHex = candidate;
321 break;
322 }
323 }
324 // Find overlays can cover prose, code, or message bubbles. Flatten them onto
325 // the editor canvas so each imported foreground has one predictable surface.
326 const searchMatchBackground =
327 solidOver(canvas, "editor.findMatchHighlightBackground") ?? derived.searchMatchBackground;
328 const searchMatchActiveBackground =
329 solidOver(canvas, "editor.findMatchBackground") ?? derived.searchMatchActiveBackground;
330
331 const overrides: Partial<Record<ThemeColorRole, string>> = {
332 canvas: canvasHex,
333 text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"),
334 textMuted: readableOn(
335 canvasHex,
336 derived.textMuted,
337 "descriptionForeground",
338 "disabledForeground",
339 ),
340 surface: solidOver(canvas, "editorWidget.background") ?? derived.surface,
341 surfaceRaised: surfaceRaisedHex,
342 surfaceOverlay:
343 solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ??
344 derived.surfaceOverlay,
345 border:
346 solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border,
347 input: inputHex,
348 placeholder: readableOn(surfaceRaisedHex, derived.placeholder, "input.placeholderForeground"),
349 error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"),
350 warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"),
351 accentSurface:
352 solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ??
353 derived.accentSurface,
354 codeBackground: solidOver(canvas, "textCodeBlock.background") ?? derived.codeBackground,
355 searchMatchBackground,
356 searchMatchForeground: readableOn(
357 searchMatchBackground,
358 derived.searchMatchForeground,
359 "editor.findMatchHighlightForeground",
360 "editor.foreground",
361 "foreground",
362 ),
363 searchMatchActiveBackground,
364 searchMatchActiveForeground: readableOn(
365 searchMatchActiveBackground,
366 derived.searchMatchActiveForeground,
367 "editor.findMatchForeground",
368 "editor.foreground",
369 "foreground",
370 ),
371 sidebar: sidebarHex,
372 sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"),
373 sidebarBorder: solidOver(sidebar, "sideBar.border") ?? derived.sidebarBorder,
374 sidebarRowHover: solidOver(sidebar, "list.hoverBackground") ?? derived.sidebarRowHover,
375 sidebarRowActive:
376 solidOver(sidebar, "list.inactiveSelectionBackground", "list.hoverBackground") ??
377 derived.sidebarRowActive,
378 sidebarRowSelected:
379 solidOver(sidebar, "list.activeSelectionBackground") ?? derived.sidebarRowSelected,
380 terminalBackground: terminalHex,
381 terminalForeground: readableOn(terminalHex, derived.terminalForeground, "terminal.foreground"),
382 terminalCursor:
383 solidOver(terminal, "terminalCursor.foreground", "editorCursor.foreground") ??
384 derived.terminalCursor,
385 terminalSelection:
386 solidOver(terminal, "terminal.selectionBackground", "editor.selectionBackground") ??
387 derived.terminalSelection,
388 terminalScrollbar:
389 solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar,
390 };
391 overrides.accent = accentHex;
392 overrides.focus = accentHex;
393 overrides.messageAction = actionHex;
394 overrides.messageActionForeground = readableOn(
395 actionHex,
396 derived.messageActionForeground,
397 "button.foreground",
398 );
399 overrides.accentForeground = readableOn(accentHex, derived.accentForeground, "button.foreground");
400
401 // Reuse the theme-file parser so ids, names, and color values go through the
402 // same validation as a hand-written file.
403 const name = resolveName(value);
404 const generatedId = themeIdFromName(name);
405 return parseThemeFile({
406 version: THEME_FILE_VERSION,
407 ...(isReservedThemeId(generatedId) ? { id: `${generatedId}-vscode` } : {}),
408 name,
409 appearance,
410 colors: { ...derived, ...overrides },
411 });
412}
413
414/**
415 * Extensions ship a family of themes (GitHub: dark, light, dark-colorblind,
416 * light-colorblind, dark-dimmed, ...). When several are imported together,
417 * a light and a dark file whose names differ only by the appearance word
418 * become one dual-mode theme; everything else stays its own theme.
419 */
420export function pairVsCodeThemes(
421 themes: ReadonlyArray<ThemeDefinition>,
422 options?: {
423 pairedId?: (light: ThemeDefinition, dark: ThemeDefinition) => string;
424 },
425): ReadonlyArray<ThemeDefinition> {
426 const stripAppearance = (label: string) =>
427 label
428 .replace(/\b(?:light|dark)\b/gi, " ")
429 .replace(/\s+/g, " ")
430 .trim();
431
432 type Group = { light: ThemeDefinition[]; dark: ThemeDefinition[]; order: number };
433 const groups = new Map<string, Group>();
434 const passthrough: Array<{ theme: ThemeDefinition; order: number }> = [];
435 themes.forEach((theme, order) => {
436 // Only single-appearance themes with an appearance word in the name can
437 // pair, and only when the rest of the name can still identify the pair
438 // ("Dark+" and "Light+" would pair as "+"); anything else is already
439 // what the user asked for.
440 const key = stripAppearance(theme.label);
441 if (getThemeModes(theme).length !== 1 || key === theme.label || !/[a-z0-9]/i.test(key)) {
442 passthrough.push({ theme, order });
443 return;
444 }
445 const group = groups.get(key) ?? { light: [], dark: [], order };
446 group[theme.appearance].push(theme);
447 groups.set(key, group);
448 });
449
450 const paired: Array<{ theme: ThemeDefinition; order: number }> = [];
451 for (const [key, group] of groups) {
452 // Ambiguity (two darks for one light) is not guessed at, and a pair
453 // whose stripped name collides with a built-in id ("Grove Light" +
454 // "Grove Dark" -> the reserved "grove") stays two single themes rather
455 // than failing the whole batch.
456 if (group.light.length === 1 && group.dark.length === 1) {
457 try {
458 paired.push({
459 order: group.order,
460 theme: parseThemeFile({
461 version: THEME_FILE_VERSION,
462 ...(options?.pairedId ? { id: options.pairedId(group.light[0]!, group.dark[0]!) } : {}),
463 name: key,
464 appearance: "light",
465 colors: group.light[0]!.colors,
466 variants: { dark: group.dark[0]!.colors },
467 }),
468 });
469 continue;
470 } catch {
471 // Fall through to the individual themes below.
472 }
473 }
474 for (const theme of [...group.light, ...group.dark]) {
475 paired.push({ theme, order: group.order });
476 }
477 }
478
479 return [...passthrough, ...paired].sort((a, b) => a.order - b.order).map((entry) => entry.theme);
480}
481
482/**
483 * Some extensions reuse one display name across every file: Dracula ships
484 * dracula.json and dracula-soft.json that both say "Dracula". When a batch
485 * carries the same id twice, the file names are the only thing that tells
486 * the variants apart, so colliding entries are relabelled from their file
487 * name (and numbered only when even that collides).
488 */
489export function resolveThemeLabelCollisions(
490 entries: ReadonlyArray<{ theme: ThemeDefinition; sourceName?: string | undefined }>,
491): ReadonlyArray<ThemeDefinition> {
492 // Null when the new name is unusable (reserved id, invalid characters).
493 const rename = (theme: ThemeDefinition, name: string): ThemeDefinition | null => {
494 try {
495 return parseThemeFile({
496 version: THEME_FILE_VERSION,
497 name: name.slice(0, 48),
498 appearance: theme.appearance,
499 colors: theme.colors,
500 ...(theme.variants ? { variants: theme.variants } : {}),
501 ...(theme.managed ? { managed: true } : {}),
502 });
503 } catch {
504 return null;
505 }
506 };
507
508 const counts = new Map<string, number>();
509 for (const entry of entries) {
510 counts.set(entry.theme.id, (counts.get(entry.theme.id) ?? 0) + 1);
511 }
512 const relabelled = entries.map(({ theme, sourceName }) => {
513 if ((counts.get(theme.id) ?? 0) < 2) return theme;
514 const stem = sourceName?.replace(/\.[^.]+$/, "");
515 const fromFile = stem ? humanizeThemeName(stem) : null;
516 const renamed =
517 fromFile && fromFile.toLowerCase() !== theme.label.toLowerCase()
518 ? rename(theme, fromFile)
519 : null;
520 return renamed ?? theme;
521 });
522
523 const seen = new Set<string>();
524 return relabelled.map((theme) => {
525 if (!seen.has(theme.id)) {
526 seen.add(theme.id);
527 return theme;
528 }
529 for (let suffix = 2; suffix < 100; suffix += 1) {
530 const candidate = rename(theme, `${theme.label} ${suffix}`);
531 if (candidate && !seen.has(candidate.id)) {
532 seen.add(candidate.id);
533 return candidate;
534 }
535 }
536 return theme;
537 });
538}