diff --git a/apps/web/src/openVsxThemes.test.ts b/apps/web/src/openVsxThemes.test.ts index 4775a0188a08..a692fd7ef76f 100644 --- a/apps/web/src/openVsxThemes.test.ts +++ b/apps/web/src/openVsxThemes.test.ts @@ -404,7 +404,7 @@ describe("Open VSX themes", () => { expect(paired.label).toBe("Demo"); expect(themeColorToHex(paired.colors.canvas)).toBe("#fafafa"); expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.canvas)).toBe("#111111"); - expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.text)).toBe("#eeeeee"); + expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.text)).toBe("#f3f3f3"); packagedManifest.contributes.themes[0]!.label = "Renamed Dark"; packagedManifest.contributes.themes[1]!.label = "Renamed Light"; diff --git a/apps/web/src/openVsxThemes.ts b/apps/web/src/openVsxThemes.ts index 90d0d62ceade..291d95a82b07 100644 --- a/apps/web/src/openVsxThemes.ts +++ b/apps/web/src/openVsxThemes.ts @@ -46,13 +46,11 @@ const USED_WORKBENCH_COLORS = new Set([ "descriptionForeground", "disabledForeground", "dropdown.background", - "dropdown.border", "editor.background", "editor.foreground", "editor.selectionBackground", "editorCursor.foreground", "editorError.foreground", - "editorGroup.border", "editorPane.background", "editorWarning.foreground", "editorWidget.background", @@ -62,11 +60,14 @@ const USED_WORKBENCH_COLORS = new Set([ "input.border", "input.placeholderForeground", "list.activeSelectionBackground", + "list.errorForeground", "list.hoverBackground", "list.inactiveSelectionBackground", + "list.warningForeground", "menu.background", "panel.background", "panel.border", + "problemsWarningIcon.foreground", "progressBar.background", "quickInput.background", "scrollbarSlider.background", diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index a836f7e0c2eb..d0d0f3c64d97 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -34,8 +34,13 @@ import { updateCustomTheme, CUSTOM_THEMES_STORAGE_KEY, createManagedThemeColors, + createThemeStatusColors, createVividThemeColors, getDefaultThemeColors, + limitThemeColorStep, + readableThemeColorOn, + shiftThemeColorLightness, + themeColorDistance, themeColorToHex, toCanonicalThemeColor, THEME_FILE_VERSION, @@ -175,6 +180,117 @@ describe("theme files", () => { } }); + it("keeps vivid muted text muted instead of at body strength", () => { + for (const [appearance, canvas, accent] of [ + ["dark", "#101a2c", "#4f8fe8"], + ["dark", "#292828", "#a89984"], + ["light", "#f4f9f2", "#1d8a4e"], + ] as const) { + const colors = createVividThemeColors(appearance, canvas, accent); + // Solving muted text for readability alone left it identical to body + // text on dark canvases; it is a mix toward its surface at the stock + // palettes' muted strength. + expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeLessThan( + contrastRatio(colors.text, colors.muted), + ); + expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeLessThan( + contrastRatio(colors.text, colors.surfaceRaised), + ); + expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeGreaterThanOrEqual(4.5); + } + }); + + it("compresses the surface ramp on mid-dark canvases", () => { + const lightness = (value: string) => + Number.parseFloat(/^oklch\(([0-9.]+)/.exec(value)?.[1] ?? "NaN"); + const step = (colors: ReturnType) => + lightness(colors.messageSurface) - lightness(colors.canvas); + const nearBlack = createVividThemeColors("dark", "#0a0a0a", "#346bf1"); + const midDark = createVividThemeColors("dark", "#292828", "#a89984"); + expect(step(midDark)).toBeLessThan(step(nearBlack)); + // Still a visible step, and still ordered. + expect(step(midDark)).toBeGreaterThan(0.05); + expect(lightness(midDark.surface)).toBeGreaterThan(lightness(midDark.canvas)); + expect(lightness(midDark.surfaceRaised)).toBeGreaterThan(lightness(midDark.surface)); + expect(lightness(midDark.surfaceOverlay)).toBeGreaterThan(lightness(midDark.surfaceRaised)); + // Light canvases keep the full step. + const light = createVividThemeColors("light", "#fdf6ec", "#c2571b"); + expect(lightness(light.canvas) - lightness(light.messageSurface)).toBeGreaterThan(0.09); + }); + + it("builds derived text from a seeded foreground pulled toward the stock text", () => { + const lightness = (value: string) => + Number.parseFloat(/^oklch\(([0-9.]+)/.exec(value)?.[1] ?? "NaN"); + const hue = (value: string) => + Number.parseFloat(/^oklch\([0-9.]+ [0-9.]+ ([0-9.]+)/.exec(value)?.[1] ?? "NaN"); + // Editor foregrounds are code colors at mid lightness; primary text + // travels most of the way to the stock near-white while keeping a + // tint of the seed's hue, and every derived text role follows it. + const seeded = createVividThemeColors("dark", "#292828", "#a89984", { text: "#d4be98" }); + const seedL = lightness(toCanonicalThemeColor("#d4be98")!); + expect(lightness(seeded.text)).toBeGreaterThan(seedL + 0.08); + expect(lightness(seeded.text)).toBeLessThan(0.97); + expect(Math.abs(hue(seeded.text) - hue(toCanonicalThemeColor("#d4be98")!))).toBeLessThan(6); + for (const role of ["codeForeground", "sidebarForeground", "messageForeground"] as const) { + expect(asHex(seeded[role])).toBe(asHex(seeded.text)); + } + // Light canvases pull toward the stock near-black instead. + const light = createVividThemeColors("light", "#fbf1c7", "#7c6f64", { text: "#654735" }); + expect(lightness(light.text)).toBeLessThan(lightness(toCanonicalThemeColor("#654735")!) - 0.08); + expect(lightness(light.text)).toBeGreaterThan(0.27); + // A foreground that cannot read on the canvas keeps its hue and gains lightness. + const lifted = createVividThemeColors("dark", "#101010", "#69b1ff", { text: "#3a2f2a" }); + expect(asHex(lifted.text)).not.toBe("#3a2f2a"); + expect(contrastRatio(lifted.text, lifted.canvas)).toBeGreaterThanOrEqual(4.5); + // Without a seed, text is still solved to AAA. + const synthesized = createVividThemeColors("dark", "#292828", "#a89984"); + expect(contrastRatio(synthesized.text, synthesized.canvas)).toBeGreaterThanOrEqual(7); + }); + + it("derives status surfaces and foregrounds from theme-supplied signals", () => { + const status = createThemeStatusColors("#292828", { error: "#ea6962", warning: "#d8a657" }); + expect(asHex(status.error)).toBe("#ea6962"); + expect(asHex(status.warning)).toBe("#d8a657"); + expect(contrastRatio(status.errorForeground, status.errorSurface)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(status.warningForeground, status.warningSurface)).toBeGreaterThanOrEqual( + 4.5, + ); + // The surface is the signal laid over the canvas, not the standard red. + expect(themeColorDistance(status.errorSurface, "#292828")).toBeLessThan(0.15); + // Without signals the standard pair still applies. + const standard = createThemeStatusColors("#0a0a0a"); + expect(asHex(standard.error)).toBe("#fb414a"); + }); + + it("lifts colors along their own hue and shifts lightness in place", () => { + const lifted = readableThemeColorOn("#928374", "#292828"); + expect(contrastRatio(lifted, "#292828")).toBeGreaterThanOrEqual(4.5); + const hue = (value: string) => + Number.parseFloat(/^oklch\([0-9.]+ [0-9.]+ ([0-9.]+)/.exec(value)?.[1] ?? "NaN"); + expect(Math.abs(hue(lifted) - hue(toCanonicalThemeColor("#928374")!))).toBeLessThan(3); + // Already readable colors are returned as they are. + expect(asHex(readableThemeColorOn("#d4be98", "#292828"))).toBe("#d4be98"); + const shifted = shiftThemeColorLightness("#a89984", 0.06); + expect(Math.abs(hue(shifted) - hue(toCanonicalThemeColor("#a89984")!))).toBeLessThan(3); + expect(contrastRatio(shifted, "#292828")).toBeGreaterThan(contrastRatio("#a89984", "#292828")); + expect(themeColorDistance("#292828", "#292828")).toBe(0); + expect(themeColorDistance("#292828", "#32302f")).toBeGreaterThan(0.02); + }); + + it("limits a color's lightness step off its base along a straight OKLab line", () => { + const lightness = (value: string) => + Number.parseFloat(/^oklch\(([0-9.]+)/.exec(value)?.[1] ?? "NaN"); + const base = toCanonicalThemeColor("#1e1e2e")!; + const limited = limitThemeColorStep("#585b70", "#1e1e2e", 0.11); + expect(lightness(limited) - lightness(base)).toBeCloseTo(0.11, 2); + // Within the cap the color is returned as-is (canonicalized). + expect(limitThemeColorStep("#45403d", "#292828", 0.11)).toBe(toCanonicalThemeColor("#45403d")); + // Works in the light direction too. + const light = limitThemeColorStep("#acb0be", "#eff1f5", 0.11); + expect(lightness(toCanonicalThemeColor("#eff1f5")!) - lightness(light)).toBeCloseTo(0.11, 2); + }); + it("keys status colors off the canvas, not the appearance slot", () => { // Inverted seeds: a dark canvas in the light slot must still get the dark // status pair, or the alert foreground lands on a dark surface unreadable. diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 8402aeb2001b..d13086c1ded1 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -766,11 +766,15 @@ const STANDARD_STATUS_COLORS = { } as const; /** - * Status surfaces are the standard color laid over the theme's own canvas - * (the unthemed app uses 8% in light and 16% in dark), so alerts still sit on - * the palette while the signal color stays standard. + * Status surfaces are the signal color laid over the theme's own canvas (the + * unthemed app uses 8% in light and 16% in dark), so alerts still sit on the + * palette. Generated palettes use the standard red and amber; an imported + * theme can supply its own pair and the surface and foreground follow it. */ -function standardStatusColors(canvas: ThemeRgbColor): { +export function createThemeStatusColors( + canvasValue: string, + signals: Readonly<{ error?: string; warning?: string }> = {}, +): { error: string; errorForeground: string; errorSurface: string; @@ -778,6 +782,7 @@ function standardStatusColors(canvas: ThemeRgbColor): { warningForeground: string; warningSurface: string; } { + const canvas = parseThemeRgbColor(canvasValue, { r: 0, g: 0, b: 0 }); // Keyed off the canvas rather than the appearance slot: a dark canvas saved // as a light theme still needs the dark pair, or the alert foreground lands // on a dark surface unreadable. @@ -788,7 +793,8 @@ function standardStatusColors(canvas: ThemeRgbColor): { mixThemeRgbColors(canvas, parseThemeRgbColor(value, canvas), surfaceMix); // The standard foregrounds are tuned against the unthemed canvas; on a // tinted one they can fall just short, so lightness is nudged until the - // pair clears 4.5 while the hue stays standard. + // pair clears 4.5 while the hue stays put. A theme-supplied signal has no + // separate foreground, so its own hue is lifted the same way. const readableOn = (foreground: string, surface: ThemeRgbColor) => themeOklchToThemeColor( solveOklchLightness( @@ -799,18 +805,49 @@ function standardStatusColors(canvas: ThemeRgbColor): { appearance === "dark" ? "lighter" : "darker", ), ); - const errorSurface = surfaceOf(standard.error); - const warningSurface = surfaceOf(standard.warning); + const error = signals.error ?? standard.error; + const warning = signals.warning ?? standard.warning; + const errorSurface = surfaceOf(error); + const warningSurface = surfaceOf(warning); return { - error: toCanonicalThemeColor(standard.error)!, - errorForeground: readableOn(standard.errorForeground, errorSurface), + error: toCanonicalThemeColor(error)!, + errorForeground: readableOn(signals.error ?? standard.errorForeground, errorSurface), errorSurface: themeRgbToThemeColor(errorSurface), - warning: toCanonicalThemeColor(standard.warning)!, - warningForeground: readableOn(standard.warningForeground, warningSurface), + warning: toCanonicalThemeColor(warning)!, + warningForeground: readableOn(signals.warning ?? standard.warningForeground, warningSurface), warningSurface: themeRgbToThemeColor(warningSurface), }; } +export type VividThemeOptions = Readonly<{ + /** + * A foreground to build every derived text color from, for palettes that + * already own one (an imported theme's editor foreground). Editor + * foregrounds are code colors -- mid-lightness, because tokens carry the + * contrast -- so it is pulled most of the way toward the stock text + * lightness (near-white on dark, near-black on light) while keeping a + * tint of its hue; without it, text is synthesized from the accent hue. + */ + text?: string; +}>; + +/** How far a seeded foreground travels toward the stock text extreme. */ +const SEEDED_TEXT_PULL = 0.7; +/** OKLCH lightness of the stock text colors, #f5f5f5 and #27272a. */ +const STOCK_TEXT_LIGHTNESS = { dark: 0.97, light: 0.27 } as const; + +/** + * The surface ramp was tuned on near-black canvases, where a lightness step + * reads as a quiet tonal shift. The same absolute step on a mid-dark canvas + * (Gruvbox, Solarized, Nord all sit around L 0.3) lands on a conspicuous grey + * slab, so steps compress as the canvas brightens. Light canvases have no + * such headroom problem and keep the full step. + */ +function vividRampScale(canvas: ThemeOklch, dark: boolean): number { + if (!dark) return 1; + return Math.min(1, Math.max(0.5, 0.19 / Math.max(canvas.L, 0.01))); +} + /** * Derive a full palette from two exact seed colors, in OKLCH. Surfaces climb a * perceptually even lightness ramp that carries the accent hue at low chroma, @@ -821,6 +858,7 @@ export function createVividThemeColors( appearance: ThemeAppearance, backgroundValue: string, accentValue: string, + options: VividThemeOptions = {}, ): ThemeColors { const defaults = getDefaultThemeColors(appearance); const canvasRgb = parseThemeRgbColor( @@ -835,25 +873,53 @@ export function createVividThemeColors( // still gets light text and raised surfaces. 0.179 is the relative // luminance where white and black text have equal contrast headroom. const dark = themeRelativeLuminance(canvasRgb) < 0.179; - const hue = accent.C < 0.02 ? canvas.h : accent.h; + const seededSource = options.text ? parseThemeColor(options.text)?.color : undefined; + const seededText = seededSource + ? mixThemeOklch( + seededSource, + { L: dark ? STOCK_TEXT_LIGHTNESS.dark : STOCK_TEXT_LIGHTNESS.light, C: 0, h: 0 }, + SEEDED_TEXT_PULL, + ) + : undefined; + // The ramp's tint hue: the accent when it has one, else the canvas, else + // (a neutral canvas under a neutral accent) the seeded text, whose warmth + // or coolness is the palette's remaining voice. A neutral canvas's own hue + // is rounding noise and would cast the ramp at random. + const hue = + accent.C >= 0.02 + ? accent.h + : canvas.C >= 0.01 || !seededSource || seededSource.C < 0.02 + ? canvas.h + : seededSource.h; const tintC = Math.min(0.045, Math.max(0.008, accent.C * 0.22)); - const step = dark ? 1 : -1; + const step = (dark ? 1 : -1) * vividRampScale(canvas, dark); + // Surfaces are steps of the canvas. A tinted canvas keeps its own hue and + // at least its own chroma up the ramp, so a cream canvas steps into deeper + // cream rather than grey; only a neutral canvas takes the accent's tint. + const canvasTinted = canvas.C >= 0.01; const surfaceAt = (deltaL: number, chroma = tintC): ThemeOklch => ({ L: Math.min(0.98, Math.max(0.05, canvas.L + step * deltaL)), - C: chroma, - h: hue, + C: canvasTinted ? Math.max(chroma, canvas.C) : chroma, + h: canvasTinted ? canvas.h : hue, }); const themeColor = (color: ThemeOklch) => themeOklchToThemeColor(color); // Text carries a whisper of the accent hue instead of falling back to a - // fixed foreground, and is solved to WCAG AAA against the canvas. - const textBase: ThemeOklch = { + // fixed foreground, and is solved to WCAG AAA against the canvas. A + // supplied foreground is the theme's own voice: it only has to read (AA), + // so low-contrast palettes like Solarized keep their character. + const textBase: ThemeOklch = seededText ?? { L: dark ? 0.95 : 0.2, C: Math.min(0.035, accent.C * 0.25), h: hue, }; - const text = solveOklchLightness(textBase, canvasRgb, 7, dark ? "lighter" : "darker"); + const text = solveOklchLightness( + textBase, + canvasRgb, + seededText ? 4.5 : 7, + dark ? "lighter" : "darker", + ); const textRgb = themeOklchToRgb(text); const textMutedRgb = standardMutedThemeText(canvasRgb, textRgb); @@ -891,14 +957,19 @@ export function createVividThemeColors( themeOklchToThemeColor( solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker"), ); - const mutedForeground = foregroundOn(mutedRgb); - const placeholder = foregroundOn(surfaceRaisedRgb); + // Secondary text is the primary text pulled toward its surface to the + // stock palettes' muted strength, not merely "still readable": solving for + // readability alone leaves it identical to body text on a dark canvas. + const mutedOn = (surfaceRgb: ThemeRgbColor): string => + themeRgbToThemeColor(standardMutedThemeText(surfaceRgb, textRgb)); + const mutedForeground = mutedOn(mutedRgb); + const placeholder = mutedOn(surfaceRaisedRgb); const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) }; return { ...defaults, - ...standardStatusColors(canvasRgb), + ...createThemeStatusColors(themeRgbToThemeColor(canvasRgb)), canvas: themeRgbToThemeColor(canvasRgb), // The top bar shares the canvas so the main panel reads as one surface. chrome: themeRgbToThemeColor(canvasRgb), @@ -954,6 +1025,84 @@ export function createVividThemeColors( }; } +/** + * Keep a color's hue and chroma but move its lightness away from `surface` + * until it clears `minContrast`. Palettes that fail a contrast check keep + * their own voice this way instead of being swapped for an unrelated color. + */ +export function readableThemeColorOn(value: string, surface: string, minContrast = 4.5): string { + const surfaceRgb = parseThemeRgbColor(surface, { r: 0, g: 0, b: 0 }); + const dark = themeRelativeLuminance(surfaceRgb) < 0.179; + const base = themeRgbToOklch( + parseThemeRgbColor(value, dark ? THEME_WHITE_FOREGROUND : THEME_BLACK_FOREGROUND), + ); + return themeOklchToThemeColor( + solveOklchLightness(base, surfaceRgb, minContrast, dark ? "lighter" : "darker"), + ); +} + +/** OKLCH lightness of a color, 0 to 1. */ +export function themeColorLightness(value: string): number { + return themeRgbToOklch(parseThemeRgbColor(value, { r: 0, g: 0, b: 0 })).L; +} + +/** OKLCH chroma of a color; below about 0.05 a color reads as a neutral. */ +export function themeColorChroma(value: string): number { + return themeRgbToOklch(parseThemeRgbColor(value, { r: 0, g: 0, b: 0 })).C; +} + +/** Straight-line interpolation in OKLab, so lightness moves evenly and chroma shrinks with it. */ +function mixThemeOklch(from: ThemeOklch, to: ThemeOklch, amount: number): ThemeOklch { + const lab = ({ L, C, h }: ThemeOklch) => { + const radians = (h * Math.PI) / 180; + return { L, a: C * Math.cos(radians), b: C * Math.sin(radians) }; + }; + const start = lab(from); + const end = lab(to); + const a = start.a + (end.a - start.a) * amount; + const b = start.b + (end.b - start.b) * amount; + return { + L: start.L + (end.L - start.L) * amount, + C: Math.hypot(a, b), + h: (Math.atan2(b, a) * 180) / Math.PI, + }; +} + +/** + * Pull `value` toward `base` until its lightness step is at most `maxDeltaL`. + * The pull is a straight line in OKLab, so a strong border keeps a hint of its + * hue while landing at hairline weight; a step already within the cap is + * returned untouched. + */ +export function limitThemeColorStep(value: string, base: string, maxDeltaL: number): string { + const from = themeRgbToOklch(parseThemeRgbColor(base, { r: 0, g: 0, b: 0 })); + const to = themeRgbToOklch(parseThemeRgbColor(value, { r: 0, g: 0, b: 0 })); + const step = Math.abs(to.L - from.L); + if (step <= maxDeltaL) return toCanonicalThemeColor(value) ?? value; + return themeOklchToThemeColor(mixThemeOklch(from, to, maxDeltaL / step)); +} + +/** Shift a color's OKLCH lightness by `deltaL`, keeping hue and chroma. */ +export function shiftThemeColorLightness(value: string, deltaL: number): string { + const base = themeRgbToOklch(parseThemeRgbColor(value, { r: 0, g: 0, b: 0 })); + return themeOklchToThemeColor({ ...base, L: Math.min(1, Math.max(0, base.L + deltaL)) }); +} + +/** + * Perceptual (OKLab) distance between two colors. Around 0.02 is the smallest + * step that still reads as a distinct surface on a monitor. + */ +export function themeColorDistance(first: string, second: string): number { + const toLab = (value: string) => { + const { L, C, h } = themeRgbToOklch(parseThemeRgbColor(value, { r: 0, g: 0, b: 0 })); + const radians = (h * Math.PI) / 180; + return { L, a: C * Math.cos(radians), b: C * Math.sin(radians) }; + }; + const a = toLab(first); + const b = toLab(second); + return Math.hypot(a.L - b.L, a.a - b.a, a.b - b.b); +} + function themeContrastRatio(first: ThemeRgbColor, second: ThemeRgbColor): number { const firstLuminance = themeRelativeLuminance(first); const secondLuminance = themeRelativeLuminance(second); @@ -1140,7 +1289,7 @@ export function createManagedThemeColors( return { ...defaults, - ...standardStatusColors(canvas), + ...createThemeStatusColors(themeRgbToThemeColor(canvas)), update: themeRgbToThemeColor(accent), updateForeground: themeRgbToThemeColor(updateForeground), updateSurface: themeRgbToThemeColor(updateSurface), diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index e4fcdb907abb..3d3660acebd3 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { getThemeColorsForMode, themeColorToHex, THEME_FILE_VERSION } from "./themePalette"; +import { + getThemeColorsForMode, + themeColorToHex, + toCanonicalThemeColor, + THEME_FILE_VERSION, +} from "./themePalette"; import { isVsCodeThemeFile, pairVsCodeThemes, @@ -55,6 +60,55 @@ const VSCODE_DARK = { tokenColors: [], }; +// The Gruvbox Material Dark workbench colors that matter here, verbatim from +// the extension: transparent focus and hover, every widget surface equal to +// the editor, a dim error squiggle ahead of the real red, and a sidebar +// foreground dimmer than the editor's. +const GRUVBOX_MATERIAL_DARK = { + name: "Gruvbox Material Dark", + type: "dark", + colors: { + "editor.background": "#292828", + "editor.foreground": "#d4be98", + foreground: "#a89984", + descriptionForeground: "#928374", + focusBorder: "#45403d00", + "button.background": "#a89984", + "button.foreground": "#292828", + "textLink.foreground": "#a9b665", + "editorWidget.background": "#292828", + "dropdown.background": "#292828", + "dropdown.border": "#45403d", + "menu.background": "#292828", + "panel.background": "#292828", + "panel.border": "#292828", + "input.border": "#45403d", + "input.placeholderForeground": "#7c6f64", + "sideBar.background": "#292828", + "sideBar.foreground": "#928374", + "list.hoverBackground": "#29282800", + "list.activeSelectionBackground": "#45403d60", + "list.inactiveSelectionBackground": "#45403d48", + "editorError.foreground": "#b85651", + errorForeground: "#ea6962", + "editorWarning.foreground": "#c18f41", + "list.warningForeground": "#d8a657", + "textCodeBlock.background": "#32302f", + "terminal.foreground": "#d4be98", + "terminalCursor.foreground": "#d4be98", + "scrollbarSlider.background": "#7c6f6480", + }, + tokenColors: [], +}; + +function oklchLightness(value: string): number { + return Number.parseFloat(/^oklch\(([0-9.]+)/.exec(value)?.[1] ?? "NaN"); +} + +function oklchHue(value: string): number { + return Number.parseFloat(/^oklch\([0-9.]+ [0-9.]+ ([0-9.]+)/.exec(value)?.[1] ?? "NaN"); +} + describe("VS Code theme import", () => { it("recognises workbench themes and rejects our own files", () => { expect(isVsCodeThemeFile(VSCODE_DARK)).toBe(true); @@ -76,7 +130,10 @@ describe("VS Code theme import", () => { expect(theme.label).toBe("Pierre Dark Soft"); expect(theme.appearance).toBe("dark"); expect(asHex(theme.colors.canvas)).toBe("#171717"); - expect(asHex(theme.colors.text)).toBe("#d4d4d4"); + // Text is the editor foreground carried toward the stock near-white. + expect(oklchLightness(theme.colors.text)).toBeGreaterThan( + oklchLightness(toCanonicalThemeColor("#d4d4d4")!), + ); expect(asHex(theme.colors.accent)).toBe("#69b1ff"); expect(asHex(theme.colors.sidebar)).toBe("#101010"); expect(asHex(theme.colors.terminalBackground)).toBe("#101010"); @@ -103,6 +160,301 @@ describe("VS Code theme import", () => { ).toBeGreaterThanOrEqual(4.5); }); + it("reads a fully transparent color as unset, not as the surface", () => { + const theme = parseVsCodeThemeFile(GRUVBOX_MATERIAL_DARK); + // focusBorder #45403d00 would flatten to the canvas; the accent falls + // through to the button color instead of vanishing. + expect(asHex(theme.colors.accent)).toBe("#a89984"); + expect(asHex(theme.colors.focus)).toBe("#a89984"); + // list.hoverBackground #29282800 likewise; hover keeps a visible step. + expect(theme.colors.sidebarRowHover).not.toBe(theme.colors.sidebar); + expect(contrastRatio(theme.colors.sidebarRowHover, theme.colors.sidebar)).toBeGreaterThan(1.1); + }); + + it("keeps a tonal step when a widget surface equals the canvas", () => { + const theme = parseVsCodeThemeFile(GRUVBOX_MATERIAL_DARK); + const canvasL = oklchLightness(theme.colors.canvas); + const surfaces = [ + theme.colors.surface, + theme.colors.surfaceRaised, + theme.colors.surfaceOverlay, + theme.colors.border, + ]; + for (const surface of surfaces) { + expect(surface).not.toBe(theme.colors.canvas); + } + // Progressively lighter, the way the stock dark palette stacks. + const lightness = surfaces.map(oklchLightness); + for (let index = 0; index < lightness.length; index += 1) { + expect(lightness[index]!).toBeGreaterThan(index === 0 ? canvasL : lightness[index - 1]!); + } + // A surface the theme did distinguish is honored as given. + expect(asHex(theme.colors.codeBackground)).toBe("#32302f"); + expect(asHex(theme.colors.input)).toBe("#45403d"); + }); + + it("builds every derived text color from the theme's own foreground", () => { + const theme = parseVsCodeThemeFile(GRUVBOX_MATERIAL_DARK); + // Primary text is the editor foreground pulled toward the stock + // near-white with its warm hue kept, not the code color verbatim and not + // a synthesized neutral. + const seed = toCanonicalThemeColor("#d4be98")!; + expect(oklchLightness(theme.colors.text)).toBeGreaterThan(oklchLightness(seed) + 0.08); + expect(Math.abs(oklchHue(theme.colors.text) - oklchHue(seed))).toBeLessThan(6); + // The sidebar, code, toolbar, and message text all follow it. + for (const role of [ + "sidebarForeground", + "codeForeground", + "toolbarForeground", + "messageForeground", + "secondaryForeground", + ] as const) { + expect(asHex(theme.colors[role])).toBe(asHex(theme.colors.text)); + } + // Muted text is a dimmer relative, not body text again. + for (const role of ["mutedForeground", "placeholder", "textMuted"] as const) { + expect(contrastRatio(theme.colors[role], theme.colors.canvas)).toBeLessThan( + contrastRatio(theme.colors.text, theme.colors.canvas), + ); + expect(contrastRatio(theme.colors[role], theme.colors.canvas)).toBeGreaterThanOrEqual(4.4); + } + expect(theme.colors.secondaryLabel).toBe(theme.colors.textMuted); + expect(theme.colors.iconMuted).toBe(theme.colors.textMuted); + }); + + it("lifts an unreadable color along its own hue instead of replacing it", () => { + const theme = parseVsCodeThemeFile(GRUVBOX_MATERIAL_DARK); + // descriptionForeground #928374 is 4.0:1 on the canvas: same warm hue, + // slightly lighter, rather than a neutral grey. + const descriptionHue = oklchHue(toCanonicalThemeColor("#928374")!); + expect(Math.abs(oklchHue(theme.colors.textMuted) - descriptionHue)).toBeLessThan(3); + expect(contrastRatio(theme.colors.textMuted, theme.colors.canvas)).toBeGreaterThanOrEqual(4.5); + expect(oklchLightness(theme.colors.textMuted)).toBeGreaterThan( + oklchLightness(toCanonicalThemeColor("#928374")!), + ); + }); + + it("uses the theme's own status colors, and never white", () => { + const theme = parseVsCodeThemeFile(GRUVBOX_MATERIAL_DARK); + // editorError.foreground #b85651 is unreadable on the canvas and used to + // drag the whole family down to a white fallback; the readable red wins. + expect(asHex(theme.colors.error)).toBe("#ea6962"); + expect(asHex(theme.colors.warning)).toBe("#d8a657"); + // Surfaces and foregrounds follow the theme's signal, not the standard. + expect( + Math.abs(oklchHue(theme.colors.errorForeground) - oklchHue(theme.colors.error)), + ).toBeLessThan(3); + expect( + Math.abs(oklchHue(theme.colors.warningForeground) - oklchHue(theme.colors.warning)), + ).toBeLessThan(3); + expect( + contrastRatio(theme.colors.errorForeground, theme.colors.errorSurface), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(theme.colors.warningForeground, theme.colors.warningSurface), + ).toBeGreaterThanOrEqual(4.5); + }); + + it("derives the action hover and update family from the colors that won", () => { + const theme = parseVsCodeThemeFile(GRUVBOX_MATERIAL_DARK); + expect(asHex(theme.colors.messageAction)).toBe("#a89984"); + expect(theme.colors.messageActionHover).not.toBe(theme.colors.messageAction); + expect( + Math.abs(oklchHue(theme.colors.messageActionHover) - oklchHue(theme.colors.messageAction)), + ).toBeLessThan(3); + expect(asHex(theme.colors.update)).toBe("#a89984"); + expect(theme.colors.updateSurface).not.toBe(theme.colors.canvas); + }); + + it("maps a dim sideBar.foreground to secondary sidebar text, never a bright one", () => { + const dim = parseVsCodeThemeFile(GRUVBOX_MATERIAL_DARK); + expect(contrastRatio(dim.colors.sidebarMutedForeground, dim.colors.sidebar)).toBeLessThan( + contrastRatio(dim.colors.sidebarForeground, dim.colors.sidebar), + ); + expect( + Math.abs( + oklchHue(dim.colors.sidebarMutedForeground) - oklchHue(toCanonicalThemeColor("#928374")!), + ), + ).toBeLessThan(3); + // A theme whose sidebar text is full strength keeps the hierarchy. + const bright = parseVsCodeThemeFile({ + ...GRUVBOX_MATERIAL_DARK, + colors: { ...GRUVBOX_MATERIAL_DARK.colors, "sideBar.foreground": "#d4be98" }, + }); + expect(contrastRatio(bright.colors.sidebarMutedForeground, bright.colors.sidebar)).toBeLessThan( + contrastRatio(bright.colors.sidebarForeground, bright.colors.sidebar) * 0.9, + ); + }); + + it("prefers an accent that clears 3:1 over a barely visible focus ring", () => { + // Nord and One Dark put a near-canvas grey in focusBorder; the link and + // button colors carry the accent people recognize. + const theme = parseVsCodeThemeFile({ + name: "Nordish", + type: "dark", + colors: { + "editor.background": "#2e3440", + "editor.foreground": "#d8dee9", + focusBorder: "#3b4252", + "button.background": "#88c0d0", + }, + }); + expect(asHex(theme.colors.accent)).toBe("#88c0d0"); + // With no candidate clearing 3:1 the first visible one still wins. + const dim = parseVsCodeThemeFile({ + name: "Dim", + type: "dark", + colors: { "editor.background": "#2e3440", focusBorder: "#3b4252" }, + }); + expect(asHex(dim.colors.accent)).toBe("#3b4252"); + }); + + it("skips a status color that is not a signal", () => { + // Solarized's errorForeground is the pale pink of message text; taking + // it as the theme's red would paint destructive buttons near-white. + const theme = parseVsCodeThemeFile({ + name: "Solarish", + type: "dark", + colors: { + "editor.background": "#002b36", + "editor.foreground": "#839496", + errorForeground: "#ffeaea", + }, + }); + expect(asHex(theme.colors.error)).not.toBe("#ffeaea"); + expect(contrastRatio(theme.colors.error, theme.colors.canvas)).toBeGreaterThanOrEqual(4.5); + const hue = oklchHue(theme.colors.error); + expect(hue < 40 || hue > 340).toBe(true); + }); + + it("does not let a full-strength descriptionForeground flatten muted text", () => { + // One Dark Pro sets descriptionForeground to the editor foreground. + const theme = parseVsCodeThemeFile({ + name: "One Darkish", + type: "dark", + colors: { + "editor.background": "#282c34", + "editor.foreground": "#abb2bf", + descriptionForeground: "#abb2bf", + "input.placeholderForeground": "#abb2bf", + }, + }); + for (const role of ["textMuted", "placeholder", "mutedForeground"] as const) { + expect(contrastRatio(theme.colors[role], theme.colors.canvas)).toBeLessThan( + contrastRatio(theme.colors.text, theme.colors.canvas) * 0.9, + ); + } + }); + + it("pulls heavy borders back to hairline weight, keeping their hue", () => { + // Catppuccin Mocha: panel.border is surface2, three steps above the + // base, and dropdown.border is the mauve accent. + const theme = parseVsCodeThemeFile({ + name: "Catppuccin Mocha", + type: "dark", + colors: { + "editor.background": "#1e1e2e", + "editor.foreground": "#cdd6f4", + focusBorder: "#cba6f7", + "panel.border": "#585b70", + "input.border": "#00000000", + "dropdown.border": "#cba6f7", + }, + }); + const canvasL = oklchLightness(theme.colors.canvas); + for (const role of ["border", "input", "sidebarBorder", "toolbarBorder"] as const) { + const base = role === "sidebarBorder" ? theme.colors.sidebar : theme.colors.canvas; + expect(oklchLightness(theme.colors[role]) - oklchLightness(base)).toBeLessThanOrEqual(0.115); + expect(oklchLightness(theme.colors[role])).toBeGreaterThan(oklchLightness(base) + 0.04); + } + // The border keeps surface2's cool hue rather than turning neutral. + expect( + Math.abs(oklchHue(theme.colors.border) - oklchHue(toCanonicalThemeColor("#585b70")!)), + ).toBeLessThan(15); + // The accent-colored dropdown border never becomes the input outline. + expect(asHex(theme.colors.input)).not.toBe("#cba6f7"); + expect(oklchLightness(theme.colors.input)).toBeLessThan(canvasL + 0.115); + // A border already at hairline weight is left as the theme wrote it. + const gruvbox = parseVsCodeThemeFile(GRUVBOX_MATERIAL_DARK); + expect(asHex(gruvbox.colors.input)).toBe("#45403d"); + }); + + it("honors a quiet hover the theme steps below its sidebar", () => { + // Tokyo Night hovers 0.014 OKLab darker than the sidebar; that is a + // choice, not the same color. + const theme = parseVsCodeThemeFile({ + name: "Tokyo Nightish", + type: "dark", + colors: { + "editor.background": "#1a1b26", + "editor.foreground": "#a9b1d6", + "sideBar.background": "#16161e", + "list.hoverBackground": "#13131a", + }, + }); + expect(asHex(theme.colors.sidebarRowHover)).toBe("#13131a"); + }); + + it("keeps derived surfaces in a tinted canvas's own family", () => { + // Gruvbox Material Light: a cream canvas with a near-neutral accent. The + // derived hover and popover step into deeper cream, not grey. + const theme = parseVsCodeThemeFile({ + name: "Gruvbox Material Light", + type: "light", + colors: { + "editor.background": "#fbf1c7", + "editor.foreground": "#654735", + "button.background": "#7c6f64", + "menu.background": "#fbf1c7", + "list.hoverBackground": "#fbf1c700", + }, + }); + const canvasHue = oklchHue(theme.colors.canvas); + const chroma = (value: string) => + Number.parseFloat(/^oklch\([0-9.]+ ([0-9.]+)/.exec(value)?.[1] ?? "NaN"); + const canvasChroma = chroma(theme.colors.canvas); + for (const role of ["surfaceOverlay", "sidebarRowHover", "surfaceRaised", "muted"] as const) { + expect(Math.abs(oklchHue(theme.colors[role]) - canvasHue)).toBeLessThan(6); + expect(chroma(theme.colors[role])).toBeGreaterThanOrEqual(canvasChroma - 0.002); + expect(theme.colors[role]).not.toBe(theme.colors.canvas); + } + }); + + it("keeps the list highlight a visible step off the popover it sits on", () => { + // Gruvbox Dark Hard: the selection color sits at the same tone as the + // derived popover surface, so the hovered file in the mention picker + // vanished. + const theme = parseVsCodeThemeFile({ + name: "Gruvbox Dark Hard", + type: "dark", + colors: { + "editor.background": "#1d2021", + "editor.foreground": "#ebdbb2", + "menu.background": "#1d2021", + "list.activeSelectionBackground": "#3c383680", + }, + }); + expect( + Math.abs( + oklchLightness(theme.colors.accentSurface) - oklchLightness(theme.colors.surfaceOverlay), + ), + ).toBeGreaterThanOrEqual(0.039); + expect( + contrastRatio(theme.colors.accentSurfaceForeground, theme.colors.accentSurface), + ).toBeGreaterThanOrEqual(4.5); + // A highlight that already clears the popover is left alone (Catppuccin: mantle popover, surface0 highlight). + const catppuccin = parseVsCodeThemeFile({ + name: "Mocha", + type: "dark", + colors: { + "editor.background": "#1e1e2e", + "menu.background": "#181825", + "list.activeSelectionBackground": "#313244", + }, + }); + expect(asHex(catppuccin.colors.accentSurface)).toBe("#313244"); + }); + it("falls back to the editor background when the type is missing or odd", () => { const untyped = parseVsCodeThemeFile({ name: "Untyped", diff --git a/apps/web/src/vscodeThemeImport.ts b/apps/web/src/vscodeThemeImport.ts index 186e03fb6a34..888d4b5a24fb 100644 --- a/apps/web/src/vscodeThemeImport.ts +++ b/apps/web/src/vscodeThemeImport.ts @@ -1,7 +1,14 @@ import { + createThemeStatusColors, createVividThemeColors, getThemeModes, + limitThemeColorStep, parseThemeFile, + readableThemeColorOn, + shiftThemeColorLightness, + themeColorChroma, + themeColorDistance, + themeColorLightness, themeColorToHex, THEME_FILE_VERSION, type ThemeAppearance, @@ -184,22 +191,80 @@ function resolveName(value: Record): string { return "VS Code theme"; } +/** + * Fully transparent workbench colors are how VS Code themes hide a focus ring + * or a hover; they mean "none", not "the surface color", so they read as + * unset. Anything this faint flattens to the surface it sits on anyway. + */ +const MIN_VISIBLE_ALPHA = 0.02; + +/** + * Below this OKLab distance two surfaces are the same color. VS Code themes + * routinely give widgets, menus, and borders the editor color and lean on + * shadows and border keys for separation; our surfaces separate by tone, so + * an identical color would erase the step rather than restyle it. The bar is + * deliberately low: a theme's quiet hover (Tokyo Night steps its list hover + * 0.014 below the sidebar) is a choice to honor, not noise. + */ +const MIN_SURFACE_DISTANCE = 0.008; + +/** + * Borders are hairlines here: the stock palettes keep them within about 0.07 + * to 0.09 lightness of the surface they outline. VS Code themes size their + * border keys for one line between two panes (Catppuccin's `panel.border` is + * three surface steps up), and that weight around every card and the composer + * reads as heavy, so border roles are pulled back to this step. + */ +const MAX_HAIRLINE_STEP = 0.11; + +/** + * A list highlight (the hovered row in a menu or the mention picker) is drawn + * on the popover surface, not the canvas, and has to clear it by a visible + * step. The unthemed app uses a 4% white wash, about this much lightness. + * Themes size `list.activeSelectionBackground` against their editor, so on + * a popover one derived step up it can land on exactly the same tone. + */ +const MIN_HIGHLIGHT_STEP = 0.04; + +/** + * A status color has to be a signal: below this OKLCH chroma it is a tint + * (Solarized's `errorForeground` is a pale pink for message text), not the + * theme's red or amber. + */ +const MIN_STATUS_CHROMA = 0.05; + +/** + * Secondary text only counts as the theme's muted color while it is clearly + * dimmer than body text (the stock palettes sit near 0.3 of the text + * contrast); a theme that sets `descriptionForeground` to its editor + * foreground would otherwise flatten every label to full strength. + */ +const MUTED_CONTRAST_RATIO = 0.6; + 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): VsCodeRgba | null => { - for (const key of keys) { - const parsed = parseVsCodeColor(colors[key]); - if (parsed) return parsed; - } - return null; - }; + /** Every usable (parseable, not transparent) color among `keys`, in priority order. */ + const candidates = (...keys: ReadonlyArray): VsCodeRgba[] => + keys + .map((key) => parseVsCodeColor(colors[key])) + .filter((parsed): parsed is VsCodeRgba => parsed !== null && parsed.a >= MIN_VISIBLE_ALPHA); + const pick = (...keys: ReadonlyArray): VsCodeRgba | null => + candidates(...keys)[0] ?? null; const solidOver = (base: VsCodeRgb, ...keys: ReadonlyArray): string | null => { const parsed = pick(...keys); return parsed ? flattenOver(parsed, base) : null; }; + /** First specified color that is actually distinguishable from `base` once flattened onto it. */ + const distinctOver = (base: VsCodeRgb, ...keys: ReadonlyArray): string | null => { + const baseHex = toHex(base); + for (const parsed of candidates(...keys)) { + const flattened = flattenOver(parsed, base); + if (themeColorDistance(flattened, baseHex) >= MIN_SURFACE_DISTANCE) return flattened; + } + return null; + }; const canvasColor = pick("editor.background", "editorPane.background"); if (!canvasColor) { @@ -208,28 +273,50 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { ); } const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b }; + const canvasHex = toHex(canvas); const appearance = resolveAppearance(value, canvas); + const dark = relativeLuminance(canvas) < 0.179; - const accentColor = pick( + // The accent has to show: a focus ring or badge wants WCAG's 3:1 non-text + // contrast, and themes like Nord and One Dark put a barely-there grey in + // `focusBorder` while the link and button carry the real color. When + // nothing clears 3:1, the first visible candidate still beats none. + const accentKeys = [ "focusBorder", "button.background", "textLink.foreground", "activityBarBadge.background", "progressBar.background", "badge.background", - ); - const canvasHex = toHex(canvas); - const accentHex = accentColor ? flattenOver(accentColor, canvas) : null; + ]; + const accentHex = + candidates(...accentKeys) + .map((parsed) => flattenOver(parsed, canvas)) + .find((candidate) => contrastRatio(hexToRgb(candidate), canvas) >= 3) ?? + distinctOver(canvas, ...accentKeys); + const foregroundHex = solidOver(canvas, "editor.foreground", "foreground"); // 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); + // theme with a blue focusBorder would get blue code and text surfaces) -- + // and from the theme's own foreground, so every derived text color is a + // relative of the text the theme chose rather than a synthesized neutral. + const mutedAccentHex = accentHex ? flattenOver({ ...hexToRgb(accentHex), a: 0.2 }, canvas) : null; + const textOptions = foregroundHex ? { text: foregroundHex } : {}; + const derived = createVividThemeColors( + appearance, + canvasHex, + mutedAccentHex ?? canvasHex, + textOptions, + ); + // The accent family (update pills, banners) wants the real accent, which + // the muted floor deliberately does not carry. + const accented = accentHex + ? createVividThemeColors(appearance, canvasHex, accentHex, textOptions) + : derived; + const sidebarHex = solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar; const sidebar = hexToRgb(sidebarHex); @@ -237,86 +324,165 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { 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. */ + /** + * Foregrounds only win when they stay readable on the surface they land + * on. The first readable candidate wins; when none clears the bar, the + * theme's first choice is lifted along its own hue until it does, and only + * a theme that specified nothing falls back to the derived value (lifted + * the same way when the file replaced the surface it was solved against). + */ const readableOn = ( surface: string, fallback: string, - ...keys: ReadonlyArray + keys: ReadonlyArray, + accept: (candidate: string) => boolean = () => true, ): 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 specified = candidates(...keys) + .map((parsed) => flattenOver(parsed, surfaceRgb)) + .filter(accept); + const readable = specified.find( + (candidate) => contrastRatio(hexToRgb(candidate), surfaceRgb) >= 4.5, + ); + if (readable) return readable; + return readableThemeColorOn(specified[0] ?? fallback, surface); }; + /** Secondary text must sit noticeably below `primary` on `surface` to count as muted. */ + const dimmerThan = + (primary: string, surface: string) => + (candidate: string): boolean => + contrastRatio(hexToRgb(candidate), hexToRgb(surface)) < + contrastRatio(hexToRgb(primary), hexToRgb(surface)) * MUTED_CONTRAST_RATIO; + const hairlineOn = (surface: string, value: string): string => + limitThemeColorStep(value, surface, MAX_HAIRLINE_STEP); + /** Push a highlight off `surface` (away from the canvas) until it shows. */ + const highlightOn = (surface: string, value: string): string => { + const delta = themeColorLightness(value) - themeColorLightness(surface); + if (Math.abs(delta) >= MIN_HIGHLIGHT_STEP) return value; + return shiftThemeColorLightness(value, (dark ? 1 : -1) * MIN_HIGHLIGHT_STEP - delta); + }; + const chromatic = (candidate: string): boolean => + themeColorChroma(candidate) >= MIN_STATUS_CHROMA; + + const text = derived.text; + // Our sidebar text is the app's body text (the unthemed app aliases them); + // VS Code's `sideBar.foreground` is the dimmer tree-item color, which maps + // to our secondary sidebar text -- but only while it is actually dimmer, + // or a theme that sets it to full strength would flatten the hierarchy. + const sidebarForeground = readableThemeColorOn(text, sidebarHex); + const sidebarMutedForeground = readableOn( + sidebarHex, + derived.sidebarMutedForeground, + ["sideBar.foreground", "descriptionForeground"], + dimmerThan(sidebarForeground, sidebarHex), + ); + + // Status colors: the theme's own red and amber when it has them, lifted to + // read on the canvas, and their surfaces and foregrounds follow suit so a + // theme is not half its own palette and half ours. + const errorHex = readableOn( + canvasHex, + derived.error, + ["errorForeground", "list.errorForeground", "editorError.foreground"], + chromatic, + ); + const warningHex = readableOn( + canvasHex, + derived.warning, + ["list.warningForeground", "editorWarning.foreground", "problemsWarningIcon.foreground"], + chromatic, + ); + const status = createThemeStatusColors(canvasHex, { error: errorHex, warning: warningHex }); + const textMuted = readableOn( + canvasHex, + derived.textMuted, + ["descriptionForeground", "disabledForeground"], + dimmerThan(text, canvasHex), + ); + const surfaceOverlay = + distinctOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ?? + derived.surfaceOverlay; + const accentSurface = highlightOn( + surfaceOverlay, + distinctOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ?? + derived.accentSurface, + ); const overrides: Partial> = { + ...status, canvas: canvasHex, - text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"), - textMuted: readableOn( + text, + textMuted, + // The stock palettes use one muted color for labels, icons, and text on + // muted surfaces; the theme's choice carries to all of them. + secondaryLabel: textMuted, + iconMuted: textMuted, + mutedForeground: readableThemeColorOn(textMuted, derived.muted), + surface: distinctOver(canvas, "editorWidget.background") ?? derived.surface, + surfaceRaised: + distinctOver(canvas, "editorWidget.background", "dropdown.background") ?? + derived.surfaceRaised, + surfaceOverlay, + border: hairlineOn( canvasHex, - derived.textMuted, - "descriptionForeground", - "disabledForeground", + distinctOver(canvas, "panel.border", "contrastBorder") ?? derived.border, ), - 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, + // `dropdown.border` is usually the accent (a dropdown is a button); our + // input outline is the quiet edge of the composer and text fields. + input: hairlineOn(canvasHex, distinctOver(canvas, "input.border") ?? derived.input), + toolbarBorder: hairlineOn(canvasHex, derived.toolbarBorder), + placeholder: readableOn( + canvasHex, + derived.placeholder, + ["input.placeholderForeground"], + dimmerThan(text, canvasHex), + ), + accentSurface, + accentSurfaceForeground: readableThemeColorOn(text, accentSurface), + codeBackground: distinctOver(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, + sidebarForeground, + sidebarMutedForeground, + sidebarBorder: hairlineOn( + sidebarHex, + distinctOver(sidebar, "sideBar.border") ?? derived.sidebarBorder, + ), + sidebarRowHover: distinctOver(sidebar, "list.hoverBackground") ?? derived.sidebarRowHover, sidebarRowActive: - solidOver(sidebar, "list.inactiveSelectionBackground", "list.hoverBackground") ?? + distinctOver(sidebar, "list.inactiveSelectionBackground", "list.hoverBackground") ?? derived.sidebarRowActive, sidebarRowSelected: - solidOver(sidebar, "list.activeSelectionBackground") ?? derived.sidebarRowSelected, + distinctOver(sidebar, "list.activeSelectionBackground") ?? derived.sidebarRowSelected, terminalBackground: terminalHex, - terminalForeground: readableOn(terminalHex, derived.terminalForeground, "terminal.foreground"), + terminalForeground: readableOn(terminalHex, derived.terminalForeground, [ + "terminal.foreground", + ]), terminalCursor: solidOver(terminal, "terminalCursor.foreground", "editorCursor.foreground") ?? derived.terminalCursor, terminalSelection: - solidOver(terminal, "terminal.selectionBackground", "editor.selectionBackground") ?? + distinctOver(terminal, "terminal.selectionBackground", "editor.selectionBackground") ?? derived.terminalSelection, terminalScrollbar: - solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar, + distinctOver(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. + overrides.update = accented.update; + overrides.updateForeground = accented.updateForeground; + overrides.updateSurface = accented.updateSurface; + overrides.accentForeground = readableOn(accentHex, derived.accentForeground, [ + "button.foreground", + ]); + // The button pair is the closest thing VS Code has to our action color, + // and its hover state is a lightness step off whichever color won. const actionHex = solidOver(canvas, "button.background") ?? accentHex; overrides.messageAction = actionHex; - overrides.messageActionForeground = readableOn( - actionHex, - derived.messageActionForeground, + overrides.messageActionForeground = readableOn(actionHex, derived.messageActionForeground, [ "button.foreground", - ); - overrides.accentForeground = readableOn( - accentHex, - derived.accentForeground, - "button.foreground", - ); + ]); + overrides.messageActionHover = shiftThemeColorLightness(actionHex, dark ? 0.06 : -0.06); } // Reuse the theme-file parser so ids, names, and color values go through the