authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-03 18:39:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 22:39:40-03:00
log2b8bb0c1881583428adbc07e3b641b21067ab41a
tree177732c17fc210d5f63efe06fa95c546aa90cc96
parentc5bc98db03ef048e2d88010876b2b3e474ecfd90
signature Signed by PGP key B5690EEEBB952194

fix(web): imported themes keep switches and focus rings visible (#14498)


2 files changed, 239 insertions(+), 33 deletions(-)

apps/web/src/vscodeThemeImport.test.ts+151
......@@ -142,6 +142,157 @@ describe("VS Code theme import", () => {
142142 ]);
143143 });
144144
145 it("prefers a visible input background over a transparent border", () => {
146 const catppuccin = parseVsCodeThemeFile({
147 name: "Catppuccin Mocha",
148 type: "dark",
149 colors: {
150 "editor.background": "#1e1e2e",
151 "input.border": "#00000000",
152 "input.background": "#313244",
153 },
154 });
155 expect(asHex(catppuccin.colors.input)).toBe("#313244");
156 });
157
158 it("keeps unchecked inputs distinct from the checked action color", () => {
159 const gruvbox = parseVsCodeThemeFile({
160 name: "Gruvbox Light Soft",
161 type: "light",
162 colors: {
163 "editor.background": "#f2e5bc",
164 "button.background": "#45858880",
165 "input.background": "#45858880",
166 "input.border": "#928374",
167 },
168 });
169 expect(
170 contrastRatio(gruvbox.colors.input, gruvbox.colors.messageAction),
171 ).toBeGreaterThanOrEqual(1.1);
172 });
173
174 it("keeps the derived input distinct when a button reuses it", () => {
175 const theme = parseVsCodeThemeFile({
176 name: "Derived input collision",
177 type: "dark",
178 colors: {
179 "editor.background": "#1e1e2e",
180 focusBorder: "#89b4fa",
181 "button.background": "#525661",
182 },
183 });
184
185 expect(asHex(theme.colors.messageAction)).toBe("#525661");
186 expect(asHex(theme.colors.input)).not.toBe("#525661");
187 expect(contrastRatio(theme.colors.input, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1);
188 expect(contrastRatio(theme.colors.input, theme.colors.messageAction)).toBeGreaterThanOrEqual(
189 1.1,
190 );
191 });
192
193 it("skips a transparent focus border for a visible accent key", () => {
194 const vitesse = parseVsCodeThemeFile({
195 name: "Vitesse Dark",
196 type: "dark",
197 colors: {
198 "editor.background": "#121212",
199 focusBorder: "#00000000",
200 "button.background": "#4d9375",
201 },
202 });
203 expect(asHex(vitesse.colors.accent)).toBe("#4d9375");
204 expect(asHex(vitesse.colors.focus)).toBe("#4d9375");
205 expect(contrastRatio(vitesse.colors.focus, vitesse.colors.canvas)).toBeGreaterThanOrEqual(1.1);
206 });
207
208 it("keeps focus visible against an explicit raised surface", () => {
209 const theme = parseVsCodeThemeFile({
210 name: "Raised focus",
211 type: "dark",
212 colors: {
213 "editor.background": "#000000",
214 focusBorder: "#111111",
215 "editorWidget.background": "#111111",
216 "button.background": "#4d9375",
217 },
218 });
219 expect(contrastRatio(theme.colors.focus, theme.colors.surfaceRaised)).toBeGreaterThanOrEqual(
220 1.1,
221 );
222 expect(asHex(theme.colors.focus)).toBe("#4d9375");
223 });
224
225 it("keeps the fallback focus visible against an explicit raised surface", () => {
226 const theme = parseVsCodeThemeFile({
227 name: "Raised fallback focus",
228 type: "dark",
229 colors: {
230 "editor.background": "#121212",
231 "editorWidget.background": "#346bf1",
232 },
233 });
234 expect(asHex(theme.colors.focus)).toBe("#ffffff");
235 expect(contrastRatio(theme.colors.focus, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1);
236 expect(contrastRatio(theme.colors.focus, theme.colors.surfaceRaised)).toBeGreaterThanOrEqual(
237 1.1,
238 );
239 });
240
241 it("skips a transparent button background for the action color", () => {
242 const theme = parseVsCodeThemeFile({
243 name: "Transparent button",
244 type: "dark",
245 colors: {
246 "editor.background": "#121212",
247 focusBorder: "#4d9375",
248 "button.background": "#00000000",
249 },
250 });
251 expect(asHex(theme.colors.messageAction)).toBe("#4d9375");
252 });
253
254 it("skips a button background that matches the raised surface", () => {
255 const theme = parseVsCodeThemeFile({
256 name: "Raised button",
257 type: "dark",
258 colors: {
259 "editor.background": "#121212",
260 focusBorder: "#4d9375",
261 "editorWidget.background": "#2a2d3a",
262 "button.background": "#2a2d3a",
263 },
264 });
265 expect(asHex(theme.colors.messageAction)).toBe("#4d9375");
266 });
267
268 it("uses a visible default accent when the file has no usable accent key", () => {
269 const theme = parseVsCodeThemeFile({
270 name: "No accent",
271 type: "dark",
272 colors: { "editor.background": "#121212", focusBorder: "#121212" },
273 });
274 expect(contrastRatio(theme.colors.focus, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1);
275 });
276
277 it("validates placeholders against the resolved raised surface", () => {
278 const lightPlus = parseVsCodeThemeFile({
279 name: "Light Plus Shape",
280 type: "light",
281 colors: {
282 "editor.background": "#eaeff3",
283 "editor.foreground": "#1f1f1f",
284 "editorWidget.background": "#ffffff",
285 "input.placeholderForeground": "#767676",
286 },
287 });
288 expect(
289 contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised),
290 ).toBeGreaterThanOrEqual(4.5);
291 expect(
292 contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised),
293 ).toBeLessThan(contrastRatio(lightPlus.colors.text, lightPlus.colors.surfaceRaised));
294 });
295
145296 it("fills every role the file omits with a readable derived value", () => {
146297 const theme = parseVsCodeThemeFile(VSCODE_DARK);
147298 const colors = getThemeColorsForMode(theme, "dark")!;
apps/web/src/vscodeThemeImport.ts+88-33
......@@ -1,5 +1,6 @@
11import {
22 createVividThemeColors,
3 getStandardThemeColors,
34 getThemeModes,
45 isReservedThemeId,
56 parseThemeFile,
......@@ -16,10 +17,10 @@ import {
1617 *
1718 * VS Code themes describe editor chrome, not an app palette: they carry a few
1819 * hundred workbench keys, leave most of them unset, and freely use 8-digit
19 * hex with alpha for overlays. So the conversion derives a complete, contrast
20 * -solved palette from the theme's editor background and accent, then layers
21 * the workbench colors it did specify on top. Anything the file omits keeps
22 * the derived value instead of falling back to an unrelated palette.
20 * hex with alpha for overlays. The conversion derives a complete palette from
21 * a visible accent and the editor background, then layers usable workbench
22 * colors on top. Foregrounds must remain readable, while authored focus and
23 * control candidates must remain distinct from adjacent surfaces and states.
2324 */
2425
2526type VsCodeRgba = { r: number; g: number; b: number; a: number };
......@@ -212,26 +213,61 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
212213 const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b };
213214 const appearance = resolveAppearance(value, canvas);
214215
215 const accentColor = pick(
216 const canvasHex = toHex(canvas);
217 const raisedSurfaceCandidateHex = solidOver(
218 canvas,
219 "editorWidget.background",
220 "dropdown.background",
221 );
222
223 /** Accent and control candidates must clear this small separation floor
224 * from every adjacent surface or state checked by the importer. */
225 const standsApart = (first: string, second: string) =>
226 contrastRatio(hexToRgb(first), hexToRgb(second)) >= 1.1;
227
228 let accentColor: VsCodeRgba | null = null;
229 let accentHex: string | null = null;
230 for (const key of [
216231 "focusBorder",
217232 "button.background",
218233 "textLink.foreground",
219234 "activityBarBadge.background",
220235 "progressBar.background",
221236 "badge.background",
222 );
223 const canvasHex = toHex(canvas);
224 const accentHex = accentColor ? flattenOver(accentColor, canvas) : null;
237 ]) {
238 const candidate = parseVsCodeColor(colors[key]);
239 if (!candidate) continue;
240 const candidateHex = flattenOver(candidate, canvas);
241 if (
242 !standsApart(candidateHex, canvasHex) ||
243 (raisedSurfaceCandidateHex !== null && !standsApart(candidateHex, raisedSurfaceCandidateHex))
244 )
245 continue;
246 accentColor = candidate;
247 accentHex = candidateHex;
248 break;
249 }
250 if (!accentColor || !accentHex) {
251 const standardAccentHex = themeColorToHex(getStandardThemeColors(appearance).accent)!;
252 accentHex =
253 [standardAccentHex, "#ffffff", "#000000"].find(
254 (candidate) =>
255 standsApart(candidate, canvasHex) &&
256 (raisedSurfaceCandidateHex === null || standsApart(candidate, raisedSurfaceCandidateHex)),
257 ) ?? standardAccentHex;
258 accentColor = parseVsCodeColor(accentHex)!;
259 }
225260
226261 // The derived palette is the floor: every role starts contrast-solved, then
227262 // the theme's own workbench colors replace what it actually specified. The
228263 // floor derives from a muted accent -- the vivid engine carries the accent
229264 // hue into every surface, which washes an imported neutral palette (a gray
230265 // theme with a blue focusBorder would get blue code and text surfaces).
231 const mutedAccentHex = accentColor
232 ? flattenOver({ r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, canvas)
233 : null;
234 const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex ?? canvasHex);
266 const mutedAccentHex = flattenOver(
267 { r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 },
268 canvas,
269 );
270 const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex);
235271 const sidebarHex =
236272 solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar;
237273 const sidebar = hexToRgb(sidebarHex);
......@@ -258,6 +294,34 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
258294 return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000";
259295 };
260296
297 const surfaceRaisedHex = raisedSurfaceCandidateHex ?? derived.surfaceRaised;
298 // The checked switch track maps to messageAction, so resolve it before
299 // choosing the input role used by the unchecked track.
300 const buttonHex = solidOver(canvas, "button.background");
301 const actionHex =
302 buttonHex && standsApart(buttonHex, canvasHex) && standsApart(buttonHex, surfaceRaisedHex)
303 ? buttonHex
304 : accentHex;
305 const inputCandidates = [
306 derived.input,
307 derived.surfaceRaised,
308 getStandardThemeColors(appearance).input,
309 "#000000",
310 "#ffffff",
311 "#808080",
312 ];
313 let inputHex =
314 inputCandidates.find(
315 (candidate) => standsApart(candidate, canvasHex) && standsApart(candidate, actionHex),
316 ) ?? "#808080";
317 for (const key of ["input.background", "input.border"]) {
318 const candidate = solidOver(canvas, key);
319 if (candidate && standsApart(candidate, canvasHex) && standsApart(candidate, actionHex)) {
320 inputHex = candidate;
321 break;
322 }
323 }
324
261325 const overrides: Partial<Record<ThemeColorRole, string>> = {
262326 canvas: canvasHex,
263327 text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"),
......@@ -268,15 +332,14 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
268332 "disabledForeground",
269333 ),
270334 surface: solidOver(canvas, "editorWidget.background") ?? derived.surface,
271 surfaceRaised:
272 solidOver(canvas, "editorWidget.background", "dropdown.background") ?? derived.surfaceRaised,
335 surfaceRaised: surfaceRaisedHex,
273336 surfaceOverlay:
274337 solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ??
275338 derived.surfaceOverlay,
276339 border:
277340 solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border,
278 input: solidOver(canvas, "input.border", "dropdown.border") ?? derived.input,
279 placeholder: readableOn(canvasHex, derived.placeholder, "input.placeholderForeground"),
341 input: inputHex,
342 placeholder: readableOn(surfaceRaisedHex, derived.placeholder, "input.placeholderForeground"),
280343 error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"),
281344 warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"),
282345 accentSurface:
......@@ -303,23 +366,15 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
303366 terminalScrollbar:
304367 solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar,
305368 };
306 if (accentHex) {
307 overrides.accent = accentHex;
308 overrides.focus = accentHex;
309 // The button pair is the closest thing VS Code has to our action color.
310 const actionHex = solidOver(canvas, "button.background") ?? accentHex;
311 overrides.messageAction = actionHex;
312 overrides.messageActionForeground = readableOn(
313 actionHex,
314 derived.messageActionForeground,
315 "button.foreground",
316 );
317 overrides.accentForeground = readableOn(
318 accentHex,
319 derived.accentForeground,
320 "button.foreground",
321 );
322 }
369 overrides.accent = accentHex;
370 overrides.focus = accentHex;
371 overrides.messageAction = actionHex;
372 overrides.messageActionForeground = readableOn(
373 actionHex,
374 derived.messageActionForeground,
375 "button.foreground",
376 );
377 overrides.accentForeground = readableOn(accentHex, derived.accentForeground, "button.foreground");
323378
324379 // Reuse the theme-file parser so ids, names, and color values go through the
325380 // same validation as a hand-written file.