Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/web/src/themePalette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,25 @@ function solveOklchLightness(
return { ...base, L: direction === "lighter" ? high : low };
}

/**
* Moves only the lightness, so a color that is too dim on `surface` keeps its
* hue instead of collapsing to black or white.
*/
export function readableThemeColorOn(color: string, surface: string, minContrast: number): string {
const parsedColor = parseThemeColor(color);
const parsedSurface = parseThemeColor(surface);
if (!parsedColor || !parsedSurface) return color;
const surfaceRgb = themeOklchToRgb(parsedSurface.color);
if (themeContrastRatio(themeOklchToRgb(parsedColor.color), surfaceRgb) >= minContrast) {
return color;
}
const direction = themeRelativeLuminance(surfaceRgb) < 0.179 ? "lighter" : "darker";
const solved = solveOklchLightness(parsedColor.color, surfaceRgb, minContrast, direction);
// Return the 8-bit color the solver measured: the unrounded OKLCH can round
// to a neighbor just under the target once it is stored and read back.
return themeRgbToHexColor(themeOklchToRgb(solved));
}

const STANDARD_STATUS_COLORS = {
light: {
error: "#fb2c36",
Expand Down
51 changes: 50 additions & 1 deletion apps/web/src/vscodeThemeImport.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vite-plus/test";

import { getThemeColorsForMode, themeColorToHex, THEME_FILE_VERSION } from "./themePalette";
import {
getThemeColorsForMode,
themeColorToHex,
THEME_FILE_VERSION,
toCanonicalThemeColor,
} from "./themePalette";
import {
isVsCodeThemeFile,
pairVsCodeThemes,
Expand Down Expand Up @@ -30,6 +35,11 @@ function contrastRatio(first: string, second: string): number {
return (lighter + 0.05) / (darker + 0.05);
}

function oklchParts(value: string): { chroma: number; hue: number } {
const [, chroma, hue] = /^oklch\(\S+ (\S+) (\S+)\)$/.exec(value)!;
return { chroma: Number(chroma), hue: Number(hue) };
}

const VSCODE_DARK = {
name: "pierre-dark-soft",
type: "dark",
Expand Down Expand Up @@ -293,6 +303,45 @@ describe("VS Code theme import", () => {
expect(hc.appearance).toBe("light");
});

it.each([
["dark", "#282828"],
["light", "#ffffff"],
] as const)(
"keeps a dim status color's hue on a %s canvas instead of replacing it",
(type, canvas) => {
const theme = parseVsCodeThemeFile({
name: "Gruvbox",
type,
colors: {
"editor.background": canvas,
"editorError.foreground": "#fb4934",
"editorWarning.foreground": "#b57614",
},
});
for (const [role, source] of [
["error", "#fb4934"],
["warning", "#b57614"],
] as const) {
const imported = oklchParts(theme.colors[role]);
expect(contrastRatio(theme.colors[role], theme.colors.canvas)).toBeGreaterThanOrEqual(4.5);
expect(imported.chroma).toBeGreaterThan(0.1);
// 8-bit sRGB can only approximate the source hue at the new lightness.
expect(
Math.abs(imported.hue - oklchParts(toCanonicalThemeColor(source)!).hue),
).toBeLessThan(2);
}
},
);

it("keeps the derived status color when the file hides its own", () => {
const theme = parseVsCodeThemeFile({
name: "Hidden squiggles",
type: "dark",
colors: { "editor.background": "#101010", "editorError.foreground": "#ff000000" },
});
expect(oklchParts(theme.colors.error).chroma).toBeGreaterThan(0.1);
});

it("stays readable when the file replaces a surface but not its foreground", () => {
const theme = parseVsCodeThemeFile({
name: "Split",
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/vscodeThemeImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getThemeModes,
isReservedThemeId,
parseThemeFile,
readableThemeColorOn,
themeColorToHex,
themeIdFromName,
THEME_FILE_VERSION,
Expand Down Expand Up @@ -251,6 +252,15 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
if (isReadable(fallback)) return fallback;
return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000";
};
// Status colors also fill controls such as destructive buttons, which carry
// white text, so a dim one is lightened along its hue rather than replaced
// by readableOn's black or white. One hidden in the canvas has no hue to
// keep, so the derived color stands in.
const readableStatusColor = (fallback: string, ...keys: ReadonlyArray<string>): string => {
const specified = solidOver(canvas, ...keys);
const base = specified && standsApart(specified, canvasHex) ? specified : fallback;
return readableThemeColorOn(base, canvasHex, 4.5);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

const surfaceRaisedHex = raisedSurfaceCandidateHex ?? derived.surfaceRaised;
// The checked switch track maps to messageAction, so resolve it before
Expand Down Expand Up @@ -302,8 +312,8 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border,
input: inputHex,
placeholder: readableOn(surfaceRaisedHex, derived.placeholder, "input.placeholderForeground"),
error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"),
warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"),
error: readableStatusColor(derived.error, "editorError.foreground", "errorForeground"),
warning: readableStatusColor(derived.warning, "editorWarning.foreground"),
accentSurface:
solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ??
derived.accentSurface,
Expand Down
Loading