import { createVividThemeColors, getThemeModes, parseThemeFile, themeColorToHex, THEME_FILE_VERSION, type ThemeAppearance, type ThemeColorRole, type ThemeDefinition,} from "./themePalette"; /** * Best-effort import of a VS Code color theme (`*-color-theme.json`). * * VS Code themes describe editor chrome, not an app palette: they carry a few * hundred workbench keys, leave most of them unset, and freely use 8-digit * hex with alpha for overlays. So the conversion derives a complete, contrast * -solved palette from the theme's editor background and accent, then layers * the workbench colors it did specify on top. Anything the file omits keeps * the derived value instead of falling back to an unrelated palette. */ type VsCodeRgba = { r: number; g: number; b: number; a: number };type VsCodeRgb = { r: number; g: number; b: number }; function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value);} /** sRGB transfer function, and its inverse, shared by the wide-gamut path. */function decodeGamma(value: number): number { return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;} function encodeGamma(value: number): number { const clamped = Math.max(0, Math.min(1, value)); return clamped <= 0.0031308 ? clamped * 12.92 : 1.055 * clamped ** (1 / 2.4) - 0.055;} /** * `color(display-p3 r g b / a)` shows up in themes authored for wide-gamut * displays. Out-of-gamut colors clip to the sRGB edge, which is what a browser * on an sRGB screen shows anyway. */function parseColorFunction(value: string): VsCodeRgba | null { const match = /^color\(\s*(display-p3|srgb)\s+([^)]+)\)$/i.exec(value); if (!match) return null; const [space, body] = [match[1]!.toLowerCase(), match[2]!]; const [channelPart, alphaPart] = body.split("/"); const channels = channelPart! .trim() .split(/\s+/) .map((part) => (part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part))); if (channels.length !== 3 || channels.some((channel) => !Number.isFinite(channel))) return null; const alphaRaw = alphaPart?.trim(); const alpha = alphaRaw === undefined ? 1 : alphaRaw.endsWith("%") ? Number.parseFloat(alphaRaw) / 100 : Number.parseFloat(alphaRaw); if (!Number.isFinite(alpha)) return null; const [red, green, blue] = channels as [number, number, number]; if (space === "srgb") { return { r: red * 255, g: green * 255, b: blue * 255, a: Math.max(0, Math.min(1, alpha)) }; } const [linearRed, linearGreen, linearBlue] = [red, green, blue].map(decodeGamma) as [ number, number, number, ]; // Display P3 linear -> sRGB linear. const srgb = [ 1.2249401762805 * linearRed - 0.2249401762805 * linearGreen, -0.042056961239 * linearRed + 1.042056961239 * linearGreen, -0.0196375547643 * linearRed - 0.0786360655012 * linearGreen + 1.0982736202656 * linearBlue, ].map((channel) => encodeGamma(channel) * 255) as [number, number, number]; return { r: srgb[0], g: srgb[1], b: srgb[2], a: Math.max(0, Math.min(1, alpha)) };} /** VS Code accepts #RGB, #RGBA, #RRGGBB, and #RRGGBBAA; some themes also use * CSS color() notation for wide-gamut palettes. */function parseVsCodeColor(value: unknown): VsCodeRgba | null { if (typeof value !== "string") return null; const trimmed = value.trim(); if (trimmed.startsWith("color(")) return parseColorFunction(trimmed); const hex = trimmed.replace(/^#/, ""); if (!/^(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)) return null; const expand = (part: string) => part.length === 1 ? Number.parseInt(part + part, 16) : Number.parseInt(part, 16); if (hex.length <= 4) { return { r: expand(hex[0]!), g: expand(hex[1]!), b: expand(hex[2]!), a: hex.length === 4 ? expand(hex[3]!) / 255 : 1, }; } return { r: Number.parseInt(hex.slice(0, 2), 16), g: Number.parseInt(hex.slice(2, 4), 16), b: Number.parseInt(hex.slice(4, 6), 16), a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1, };} function toHex(color: VsCodeRgb): string { const channel = (value: number) => Math.max(0, Math.min(255, Math.round(value))) .toString(16) .padStart(2, "0"); return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;} /** Overlays are semi-transparent in VS Code; our roles are opaque, so they are * composited onto whatever surface they sit on. */function flattenOver(color: VsCodeRgba, base: VsCodeRgb): string { if (color.a >= 1) return toHex(color); return toHex({ r: color.r * color.a + base.r * (1 - color.a), g: color.g * color.a + base.g * (1 - color.a), b: color.b * color.a + base.b * (1 - color.a), });} function relativeLuminance(color: VsCodeRgb): number { const channel = (value: number) => { const ratio = value / 255; return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4; }; return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);} function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number { const a = relativeLuminance(first); const b = relativeLuminance(second); return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);} function hexToRgb(value: string): VsCodeRgb { return parseVsCodeColor(themeColorToHex(value) ?? value) ?? { r: 0, g: 0, b: 0, a: 1 };} /** * A VS Code theme is recognised by its workbench colors: the keys are dotted * paths (`editor.background`), which our own files never use. */export function isVsCodeThemeFile(value: unknown): boolean { if (!isRecord(value)) return false; if (value.version === THEME_FILE_VERSION) return false; const hasWorkbenchColors = isRecord(value.colors) && Object.keys(value.colors).some((key) => key.includes(".")); return hasWorkbenchColors || Array.isArray(value.tokenColors);} function resolveAppearance(value: Record<string, unknown>, canvas: VsCodeRgb): ThemeAppearance { const type = typeof value.type === "string" ? value.type.toLowerCase() : null; if (type === "light" || type === "hc-light") return "light"; if (type === "dark" || type === "hc-black") return "dark"; // Unlabelled themes (and the odd custom `type`) follow the editor surface. return relativeLuminance(canvas) < 0.179 ? "dark" : "light";} /** Extension `name` fields are often package slugs; read them as words. */export function humanizeThemeName(raw: string): string { const trimmed = raw.trim(); if (/\s/.test(trimmed) || !/[-_.]/.test(trimmed)) return trimmed; return trimmed .split(/[-_.]+/) .filter(Boolean) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" ");} function resolveName(value: Record<string, unknown>): string { // Judge candidates by their humanized form: a displayName of "---" // humanizes to nothing and must fall through to the name. for (const candidate of [value.displayName, value.name]) { if (typeof candidate !== "string") continue; const humanized = humanizeThemeName(candidate); if (humanized.length > 0) return humanized.slice(0, 48); } return "VS Code theme";} export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { if (!isRecord(value)) throw new Error("Theme files must contain a JSON object."); const colors = isRecord(value.colors) ? value.colors : {}; /** First key that carries a usable color, in priority order. */ const pick = (...keys: ReadonlyArray<string>): VsCodeRgba | null => { for (const key of keys) { const parsed = parseVsCodeColor(colors[key]); if (parsed) return parsed; } return null; }; const solidOver = (base: VsCodeRgb, ...keys: ReadonlyArray<string>): string | null => { const parsed = pick(...keys); return parsed ? flattenOver(parsed, base) : null; }; const canvasColor = pick("editor.background", "editorPane.background"); if (!canvasColor) { throw new Error( 'That VS Code theme has no "editor.background" color, so there is nothing to build a palette from.', ); } const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b }; const appearance = resolveAppearance(value, canvas); const accentColor = pick( "focusBorder", "button.background", "textLink.foreground", "activityBarBadge.background", "progressBar.background", "badge.background", ); const canvasHex = toHex(canvas); const accentHex = accentColor ? flattenOver(accentColor, canvas) : null; // The derived palette is the floor: every role starts contrast-solved, then // the theme's own workbench colors replace what it actually specified. The // floor derives from a muted accent -- the vivid engine carries the accent // hue into every surface, which washes an imported neutral palette (a gray // theme with a blue focusBorder would get blue code and text surfaces). const mutedAccentHex = accentColor ? flattenOver({ r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, canvas) : null; const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex ?? canvasHex); const sidebarHex = solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar; const sidebar = hexToRgb(sidebarHex); const terminalHex = solidOver(canvas, "terminal.background", "panel.background") ?? derived.terminalBackground; const terminal = hexToRgb(terminalHex); /** Foregrounds only win when they stay readable on the surface they land on; * a theme tuned for its own chrome can be unreadable on ours. */ const readableOn = ( surface: string, fallback: string, ...keys: ReadonlyArray<string> ): string => { const surfaceRgb = hexToRgb(surface); const isReadable = (candidate: string) => contrastRatio(hexToRgb(candidate), surfaceRgb) >= 4.5; const specified = solidOver(surfaceRgb, ...keys); if (specified && isReadable(specified)) return specified; // The derived fallback was solved against the derived surface. When the // file replaced that surface (a light sideBar in a dark theme, say), the // fallback has to clear the bar there too, or the text falls back to the // readable end of the greyscale for the surface actually in play. if (isReadable(fallback)) return fallback; return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000"; }; const overrides: Partial<Record<ThemeColorRole, string>> = { canvas: canvasHex, text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"), textMuted: readableOn( canvasHex, derived.textMuted, "descriptionForeground", "disabledForeground", ), surface: solidOver(canvas, "editorWidget.background") ?? derived.surface, surfaceRaised: solidOver(canvas, "editorWidget.background", "dropdown.background") ?? derived.surfaceRaised, surfaceOverlay: solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ?? derived.surfaceOverlay, border: solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border, input: solidOver(canvas, "input.border", "dropdown.border") ?? derived.input, placeholder: readableOn(canvasHex, derived.placeholder, "input.placeholderForeground"), error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"), warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"), accentSurface: solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ?? derived.accentSurface, codeBackground: solidOver(canvas, "textCodeBlock.background") ?? derived.codeBackground, sidebar: sidebarHex, sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"), sidebarBorder: solidOver(sidebar, "sideBar.border") ?? derived.sidebarBorder, sidebarRowHover: solidOver(sidebar, "list.hoverBackground") ?? derived.sidebarRowHover, sidebarRowActive: solidOver(sidebar, "list.inactiveSelectionBackground", "list.hoverBackground") ?? derived.sidebarRowActive, sidebarRowSelected: solidOver(sidebar, "list.activeSelectionBackground") ?? derived.sidebarRowSelected, terminalBackground: terminalHex, terminalForeground: readableOn(terminalHex, derived.terminalForeground, "terminal.foreground"), terminalCursor: solidOver(terminal, "terminalCursor.foreground", "editorCursor.foreground") ?? derived.terminalCursor, terminalSelection: solidOver(terminal, "terminal.selectionBackground", "editor.selectionBackground") ?? derived.terminalSelection, terminalScrollbar: solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar, }; if (accentHex) { overrides.accent = accentHex; overrides.focus = accentHex; // The button pair is the closest thing VS Code has to our action color. const actionHex = solidOver(canvas, "button.background") ?? accentHex; overrides.messageAction = actionHex; overrides.messageActionForeground = readableOn( actionHex, derived.messageActionForeground, "button.foreground", ); overrides.accentForeground = readableOn( accentHex, derived.accentForeground, "button.foreground", ); } // Reuse the theme-file parser so ids, names, and color values go through the // same validation as a hand-written file. return parseThemeFile({ version: THEME_FILE_VERSION, name: resolveName(value), appearance, colors: { ...derived, ...overrides }, });} /** * Extensions ship a family of themes (GitHub: dark, light, dark-colorblind, * light-colorblind, dark-dimmed, ...). When several are imported together, * a light and a dark file whose names differ only by the appearance word * become one dual-mode theme; everything else stays its own theme. */export function pairVsCodeThemes( themes: ReadonlyArray<ThemeDefinition>, options?: { pairedId?: (light: ThemeDefinition, dark: ThemeDefinition) => string; },): ReadonlyArray<ThemeDefinition> { const stripAppearance = (label: string) => label .replace(/\b(?:light|dark)\b/gi, " ") .replace(/\s+/g, " ") .trim(); type Group = { light: ThemeDefinition[]; dark: ThemeDefinition[]; order: number }; const groups = new Map<string, Group>(); const passthrough: Array<{ theme: ThemeDefinition; order: number }> = []; themes.forEach((theme, order) => { // Only single-appearance themes with an appearance word in the name can // pair; anything else is already what the user asked for. const key = stripAppearance(theme.label); if (getThemeModes(theme).length !== 1 || key === theme.label || key.length === 0) { passthrough.push({ theme, order }); return; } const group = groups.get(key) ?? { light: [], dark: [], order }; group[theme.appearance].push(theme); groups.set(key, group); }); const paired: Array<{ theme: ThemeDefinition; order: number }> = []; for (const [key, group] of groups) { // Ambiguity (two darks for one light) is not guessed at, and a pair // whose stripped name collides with a built-in id ("Grove Light" + // "Grove Dark" -> the reserved "grove") stays two single themes rather // than failing the whole batch. if (group.light.length === 1 && group.dark.length === 1) { try { paired.push({ order: group.order, theme: parseThemeFile({ version: THEME_FILE_VERSION, ...(options?.pairedId ? { id: options.pairedId(group.light[0]!, group.dark[0]!) } : {}), name: key, appearance: "light", colors: group.light[0]!.colors, variants: { dark: group.dark[0]!.colors }, }), }); continue; } catch { // Fall through to the individual themes below. } } for (const theme of [...group.light, ...group.dark]) { paired.push({ theme, order: group.order }); } } return [...passthrough, ...paired].sort((a, b) => a.order - b.order).map((entry) => entry.theme);} /** * Some extensions reuse one display name across every file: Dracula ships * dracula.json and dracula-soft.json that both say "Dracula". When a batch * carries the same id twice, the file names are the only thing that tells * the variants apart, so colliding entries are relabelled from their file * name (and numbered only when even that collides). */export function resolveThemeLabelCollisions( entries: ReadonlyArray<{ theme: ThemeDefinition; sourceName?: string | undefined }>,): ReadonlyArray<ThemeDefinition> { // Null when the new name is unusable (reserved id, invalid characters). const rename = (theme: ThemeDefinition, name: string): ThemeDefinition | null => { try { return parseThemeFile({ version: THEME_FILE_VERSION, name: name.slice(0, 48), appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), ...(theme.managed ? { managed: true } : {}), }); } catch { return null; } }; const counts = new Map<string, number>(); for (const entry of entries) { counts.set(entry.theme.id, (counts.get(entry.theme.id) ?? 0) + 1); } const relabelled = entries.map(({ theme, sourceName }) => { if ((counts.get(theme.id) ?? 0) < 2) return theme; const stem = sourceName?.replace(/\.[^.]+$/, ""); const fromFile = stem ? humanizeThemeName(stem) : null; const renamed = fromFile && fromFile.toLowerCase() !== theme.label.toLowerCase() ? rename(theme, fromFile) : null; return renamed ?? theme; }); const seen = new Set<string>(); return relabelled.map((theme) => { if (!seen.has(theme.id)) { seen.add(theme.id); return theme; } for (let suffix = 2; suffix < 100; suffix += 1) { const candidate = rename(theme, `${theme.label} ${suffix}`); if (candidate && !seen.has(candidate.id)) { seen.add(candidate.id); return candidate; } } return theme; });}
import {
createVividThemeColors,
getThemeModes,
parseThemeFile,
themeColorToHex,
THEME_FILE_VERSION,
type ThemeAppearance,
type ThemeColorRole,
type ThemeDefinition,
} from "./themePalette";
/**
* Best-effort import of a VS Code color theme (`*-color-theme.json`).
*
* VS Code themes describe editor chrome, not an app palette: they carry a few
* hundred workbench keys, leave most of them unset, and freely use 8-digit
* hex with alpha for overlays. So the conversion derives a complete, contrast
* -solved palette from the theme's editor background and accent, then layers
* the workbench colors it did specify on top. Anything the file omits keeps
* the derived value instead of falling back to an unrelated palette.
*/
type VsCodeRgba = { r: number; g: number; b: number; a: number };
type VsCodeRgb = { r: number; g: number; b: number };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** sRGB transfer function, and its inverse, shared by the wide-gamut path. */
function decodeGamma(value: number): number {
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
}
function encodeGamma(value: number): number {
const clamped = Math.max(0, Math.min(1, value));
return clamped <= 0.0031308 ? clamped * 12.92 : 1.055 * clamped ** (1 / 2.4) - 0.055;
}
/**
* `color(display-p3 r g b / a)` shows up in themes authored for wide-gamut
* displays. Out-of-gamut colors clip to the sRGB edge, which is what a browser
* on an sRGB screen shows anyway.
*/
function parseColorFunction(value: string): VsCodeRgba | null {
const match = /^color\(\s*(display-p3|srgb)\s+([^)]+)\)$/i.exec(value);
if (!match) return null;
const [space, body] = [match[1]!.toLowerCase(), match[2]!];
const [channelPart, alphaPart] = body.split("/");
const channels = channelPart!
.trim()
.split(/\s+/)
.map((part) => (part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part)));
if (channels.length !== 3 || channels.some((channel) => !Number.isFinite(channel))) return null;
const alphaRaw = alphaPart?.trim();
const alpha =
alphaRaw === undefined
? 1
: alphaRaw.endsWith("%")
? Number.parseFloat(alphaRaw) / 100
: Number.parseFloat(alphaRaw);
if (!Number.isFinite(alpha)) return null;
const [red, green, blue] = channels as [number, number, number];
if (space === "srgb") {
return { r: red * 255, g: green * 255, b: blue * 255, a: Math.max(0, Math.min(1, alpha)) };
}
const [linearRed, linearGreen, linearBlue] = [red, green, blue].map(decodeGamma) as [
number,
number,
number,
];
// Display P3 linear -> sRGB linear.
const srgb = [
1.2249401762805 * linearRed - 0.2249401762805 * linearGreen,
-0.042056961239 * linearRed + 1.042056961239 * linearGreen,
-0.0196375547643 * linearRed - 0.0786360655012 * linearGreen + 1.0982736202656 * linearBlue,
].map((channel) => encodeGamma(channel) * 255) as [number, number, number];
return { r: srgb[0], g: srgb[1], b: srgb[2], a: Math.max(0, Math.min(1, alpha)) };
}
/** VS Code accepts #RGB, #RGBA, #RRGGBB, and #RRGGBBAA; some themes also use
* CSS color() notation for wide-gamut palettes. */
function parseVsCodeColor(value: unknown): VsCodeRgba | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.startsWith("color(")) return parseColorFunction(trimmed);
const hex = trimmed.replace(/^#/, "");
if (!/^(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)) return null;
const expand = (part: string) =>
part.length === 1 ? Number.parseInt(part + part, 16) : Number.parseInt(part, 16);
if (hex.length <= 4) {
return {
r: expand(hex[0]!),
g: expand(hex[1]!),
b: expand(hex[2]!),
a: hex.length === 4 ? expand(hex[3]!) / 255 : 1,
};
}
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1,
};
}
function toHex(color: VsCodeRgb): string {
const channel = (value: number) =>
Math.max(0, Math.min(255, Math.round(value)))
.toString(16)
.padStart(2, "0");
return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
}
/** Overlays are semi-transparent in VS Code; our roles are opaque, so they are
* composited onto whatever surface they sit on. */
function flattenOver(color: VsCodeRgba, base: VsCodeRgb): string {
if (color.a >= 1) return toHex(color);
return toHex({
r: color.r * color.a + base.r * (1 - color.a),
g: color.g * color.a + base.g * (1 - color.a),
b: color.b * color.a + base.b * (1 - color.a),
});
}
function relativeLuminance(color: VsCodeRgb): number {
const channel = (value: number) => {
const ratio = value / 255;
return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
}
function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number {
const a = relativeLuminance(first);
const b = relativeLuminance(second);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
function hexToRgb(value: string): VsCodeRgb {
return parseVsCodeColor(themeColorToHex(value) ?? value) ?? { r: 0, g: 0, b: 0, a: 1 };
}
/**
* A VS Code theme is recognised by its workbench colors: the keys are dotted
* paths (`editor.background`), which our own files never use.
*/
export function isVsCodeThemeFile(value: unknown): boolean {
if (!isRecord(value)) return false;
if (value.version === THEME_FILE_VERSION) return false;
const hasWorkbenchColors =
isRecord(value.colors) && Object.keys(value.colors).some((key) => key.includes("."));
return hasWorkbenchColors || Array.isArray(value.tokenColors);
}
function resolveAppearance(value: Record<string, unknown>, canvas: VsCodeRgb): ThemeAppearance {
const type = typeof value.type === "string" ? value.type.toLowerCase() : null;
if (type === "light" || type === "hc-light") return "light";
if (type === "dark" || type === "hc-black") return "dark";
// Unlabelled themes (and the odd custom `type`) follow the editor surface.
return relativeLuminance(canvas) < 0.179 ? "dark" : "light";
}
/** Extension `name` fields are often package slugs; read them as words. */
export function humanizeThemeName(raw: string): string {
const trimmed = raw.trim();
if (/\s/.test(trimmed) || !/[-_.]/.test(trimmed)) return trimmed;
return trimmed
.split(/[-_.]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function resolveName(value: Record<string, unknown>): string {
// Judge candidates by their humanized form: a displayName of "---"
// humanizes to nothing and must fall through to the name.
for (const candidate of [value.displayName, value.name]) {
if (typeof candidate !== "string") continue;
const humanized = humanizeThemeName(candidate);
if (humanized.length > 0) return humanized.slice(0, 48);
}
return "VS Code theme";
}
export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
if (!isRecord(value)) throw new Error("Theme files must contain a JSON object.");
const colors = isRecord(value.colors) ? value.colors : {};
/** First key that carries a usable color, in priority order. */
const pick = (...keys: ReadonlyArray<string>): VsCodeRgba | null => {
for (const key of keys) {
const parsed = parseVsCodeColor(colors[key]);
if (parsed) return parsed;
}
return null;
};
const solidOver = (base: VsCodeRgb, ...keys: ReadonlyArray<string>): string | null => {
const parsed = pick(...keys);
return parsed ? flattenOver(parsed, base) : null;
};
const canvasColor = pick("editor.background", "editorPane.background");
if (!canvasColor) {
throw new Error(
'That VS Code theme has no "editor.background" color, so there is nothing to build a palette from.',
);
}
const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b };
const appearance = resolveAppearance(value, canvas);
const accentColor = pick(
"focusBorder",
"button.background",
"textLink.foreground",
"activityBarBadge.background",
"progressBar.background",
"badge.background",
);
const canvasHex = toHex(canvas);
const accentHex = accentColor ? flattenOver(accentColor, canvas) : null;
// The derived palette is the floor: every role starts contrast-solved, then
// the theme's own workbench colors replace what it actually specified. The
// floor derives from a muted accent -- the vivid engine carries the accent
// hue into every surface, which washes an imported neutral palette (a gray
// theme with a blue focusBorder would get blue code and text surfaces).
const mutedAccentHex = accentColor
? flattenOver({ r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, canvas)
: null;
const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex ?? canvasHex);
const sidebarHex =
solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar;
const sidebar = hexToRgb(sidebarHex);
const terminalHex =
solidOver(canvas, "terminal.background", "panel.background") ?? derived.terminalBackground;
const terminal = hexToRgb(terminalHex);
/** Foregrounds only win when they stay readable on the surface they land on;
* a theme tuned for its own chrome can be unreadable on ours. */
const readableOn = (
surface: string,
fallback: string,
...keys: ReadonlyArray<string>
): string => {
const surfaceRgb = hexToRgb(surface);
const isReadable = (candidate: string) => contrastRatio(hexToRgb(candidate), surfaceRgb) >= 4.5;
const specified = solidOver(surfaceRgb, ...keys);
if (specified && isReadable(specified)) return specified;
// The derived fallback was solved against the derived surface. When the
// file replaced that surface (a light sideBar in a dark theme, say), the
// fallback has to clear the bar there too, or the text falls back to the
// readable end of the greyscale for the surface actually in play.
if (isReadable(fallback)) return fallback;
return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000";
};
const overrides: Partial<Record<ThemeColorRole, string>> = {
canvas: canvasHex,
text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"),
textMuted: readableOn(
canvasHex,
derived.textMuted,
"descriptionForeground",
"disabledForeground",
),
surface: solidOver(canvas, "editorWidget.background") ?? derived.surface,
surfaceRaised:
solidOver(canvas, "editorWidget.background", "dropdown.background") ?? derived.surfaceRaised,
surfaceOverlay:
solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ??
derived.surfaceOverlay,
border:
solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border,
input: solidOver(canvas, "input.border", "dropdown.border") ?? derived.input,
placeholder: readableOn(canvasHex, derived.placeholder, "input.placeholderForeground"),
error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"),
warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"),
accentSurface:
solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ??
derived.accentSurface,
codeBackground: solidOver(canvas, "textCodeBlock.background") ?? derived.codeBackground,
sidebar: sidebarHex,
sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"),
sidebarBorder: solidOver(sidebar, "sideBar.border") ?? derived.sidebarBorder,
sidebarRowHover: solidOver(sidebar, "list.hoverBackground") ?? derived.sidebarRowHover,
sidebarRowActive:
solidOver(sidebar, "list.inactiveSelectionBackground", "list.hoverBackground") ??
derived.sidebarRowActive,
sidebarRowSelected:
solidOver(sidebar, "list.activeSelectionBackground") ?? derived.sidebarRowSelected,
terminalBackground: terminalHex,
terminalForeground: readableOn(terminalHex, derived.terminalForeground, "terminal.foreground"),
terminalCursor:
solidOver(terminal, "terminalCursor.foreground", "editorCursor.foreground") ??
derived.terminalCursor,
terminalSelection:
solidOver(terminal, "terminal.selectionBackground", "editor.selectionBackground") ??
derived.terminalSelection,
terminalScrollbar:
solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar,
};
if (accentHex) {
overrides.accent = accentHex;
overrides.focus = accentHex;
// The button pair is the closest thing VS Code has to our action color.
const actionHex = solidOver(canvas, "button.background") ?? accentHex;
overrides.messageAction = actionHex;
overrides.messageActionForeground = readableOn(
actionHex,
derived.messageActionForeground,
"button.foreground",
);
overrides.accentForeground = readableOn(
accentHex,
derived.accentForeground,
"button.foreground",
);
}
// Reuse the theme-file parser so ids, names, and color values go through the
// same validation as a hand-written file.
return parseThemeFile({
version: THEME_FILE_VERSION,
name: resolveName(value),
appearance,
colors: { ...derived, ...overrides },
});
}
/**
* Extensions ship a family of themes (GitHub: dark, light, dark-colorblind,
* light-colorblind, dark-dimmed, ...). When several are imported together,
* a light and a dark file whose names differ only by the appearance word
* become one dual-mode theme; everything else stays its own theme.
*/
export function pairVsCodeThemes(
themes: ReadonlyArray<ThemeDefinition>,
options?: {
pairedId?: (light: ThemeDefinition, dark: ThemeDefinition) => string;
},
): ReadonlyArray<ThemeDefinition> {
const stripAppearance = (label: string) =>
label
.replace(/\b(?:light|dark)\b/gi, " ")
.replace(/\s+/g, " ")
.trim();
type Group = { light: ThemeDefinition[]; dark: ThemeDefinition[]; order: number };
const groups = new Map<string, Group>();
const passthrough: Array<{ theme: ThemeDefinition; order: number }> = [];
themes.forEach((theme, order) => {
// Only single-appearance themes with an appearance word in the name can
// pair; anything else is already what the user asked for.
const key = stripAppearance(theme.label);
if (getThemeModes(theme).length !== 1 || key === theme.label || key.length === 0) {
passthrough.push({ theme, order });
return;
}
const group = groups.get(key) ?? { light: [], dark: [], order };
group[theme.appearance].push(theme);
groups.set(key, group);
});
const paired: Array<{ theme: ThemeDefinition; order: number }> = [];
for (const [key, group] of groups) {
// Ambiguity (two darks for one light) is not guessed at, and a pair
// whose stripped name collides with a built-in id ("Grove Light" +
// "Grove Dark" -> the reserved "grove") stays two single themes rather
// than failing the whole batch.
if (group.light.length === 1 && group.dark.length === 1) {
try {
paired.push({
order: group.order,
theme: parseThemeFile({
version: THEME_FILE_VERSION,
...(options?.pairedId ? { id: options.pairedId(group.light[0]!, group.dark[0]!) } : {}),
name: key,
appearance: "light",
colors: group.light[0]!.colors,
variants: { dark: group.dark[0]!.colors },
}),
});
continue;
} catch {
// Fall through to the individual themes below.
}
}
for (const theme of [...group.light, ...group.dark]) {
paired.push({ theme, order: group.order });
}
}
return [...passthrough, ...paired].sort((a, b) => a.order - b.order).map((entry) => entry.theme);
}
/**
* Some extensions reuse one display name across every file: Dracula ships
* dracula.json and dracula-soft.json that both say "Dracula". When a batch
* carries the same id twice, the file names are the only thing that tells
* the variants apart, so colliding entries are relabelled from their file
* name (and numbered only when even that collides).
*/
export function resolveThemeLabelCollisions(
entries: ReadonlyArray<{ theme: ThemeDefinition; sourceName?: string | undefined }>,
): ReadonlyArray<ThemeDefinition> {
// Null when the new name is unusable (reserved id, invalid characters).
const rename = (theme: ThemeDefinition, name: string): ThemeDefinition | null => {
try {
return parseThemeFile({
version: THEME_FILE_VERSION,
name: name.slice(0, 48),
appearance: theme.appearance,
colors: theme.colors,
...(theme.variants ? { variants: theme.variants } : {}),
...(theme.managed ? { managed: true } : {}),
});
} catch {
return null;
}
};
const counts = new Map<string, number>();
for (const entry of entries) {
counts.set(entry.theme.id, (counts.get(entry.theme.id) ?? 0) + 1);
}
const relabelled = entries.map(({ theme, sourceName }) => {
if ((counts.get(theme.id) ?? 0) < 2) return theme;
const stem = sourceName?.replace(/\.[^.]+$/, "");
const fromFile = stem ? humanizeThemeName(stem) : null;
const renamed =
fromFile && fromFile.toLowerCase() !== theme.label.toLowerCase()
? rename(theme, fromFile)
: null;
return renamed ?? theme;
});
const seen = new Set<string>();
return relabelled.map((theme) => {
if (!seen.has(theme.id)) {
seen.add(theme.id);
return theme;
}
for (let suffix = 2; suffix < 100; suffix += 1) {
const candidate = rename(theme, `${theme.label} ${suffix}`);
if (candidate && !seen.has(candidate.id)) {
seen.add(candidate.id);
return candidate;
}
}
return theme;
});
}