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", () => {...@@ -142,6 +142,157 @@ describe("VS Code theme import", () => {
142 ]);142 ]);
143 });143 });
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
145 it("fills every role the file omits with a readable derived value", () => {296 it("fills every role the file omits with a readable derived value", () => {
146 const theme = parseVsCodeThemeFile(VSCODE_DARK);297 const theme = parseVsCodeThemeFile(VSCODE_DARK);
147 const colors = getThemeColorsForMode(theme, "dark")!;298 const colors = getThemeColorsForMode(theme, "dark")!;
apps/web/src/vscodeThemeImport.ts+88-33
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 createVividThemeColors,2 createVividThemeColors,
3 getStandardThemeColors,
3 getThemeModes,4 getThemeModes,
4 isReservedThemeId,5 isReservedThemeId,
5 parseThemeFile,6 parseThemeFile,
...@@ -16,10 +17,10 @@ import {...@@ -16,10 +17,10 @@ import {
16 *17 *
17 * VS Code themes describe editor chrome, not an app palette: they carry a few18 * VS Code themes describe editor chrome, not an app palette: they carry a few
18 * hundred workbench keys, leave most of them unset, and freely use 8-digit19 * 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, contrast20 * hex with alpha for overlays. The conversion derives a complete palette from
20 * -solved palette from the theme's editor background and accent, then layers21 * a visible accent and the editor background, then layers usable workbench
21 * the workbench colors it did specify on top. Anything the file omits keeps22 * colors on top. Foregrounds must remain readable, while authored focus and
22 * the derived value instead of falling back to an unrelated palette.23 * control candidates must remain distinct from adjacent surfaces and states.
23 */24 */
2425
25type VsCodeRgba = { r: number; g: number; b: number; a: number };26type VsCodeRgba = { r: number; g: number; b: number; a: number };
...@@ -212,26 +213,61 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {...@@ -212,26 +213,61 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
212 const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b };213 const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b };
213 const appearance = resolveAppearance(value, canvas);214 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 [
216 "focusBorder",231 "focusBorder",
217 "button.background",232 "button.background",
218 "textLink.foreground",233 "textLink.foreground",
219 "activityBarBadge.background",234 "activityBarBadge.background",
220 "progressBar.background",235 "progressBar.background",
221 "badge.background",236 "badge.background",
222 );237 ]) {
223 const canvasHex = toHex(canvas);238 const candidate = parseVsCodeColor(colors[key]);
224 const accentHex = accentColor ? flattenOver(accentColor, canvas) : null;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
226 // The derived palette is the floor: every role starts contrast-solved, then261 // The derived palette is the floor: every role starts contrast-solved, then
227 // the theme's own workbench colors replace what it actually specified. The262 // the theme's own workbench colors replace what it actually specified. The
228 // floor derives from a muted accent -- the vivid engine carries the accent263 // floor derives from a muted accent -- the vivid engine carries the accent
229 // hue into every surface, which washes an imported neutral palette (a gray264 // hue into every surface, which washes an imported neutral palette (a gray
230 // theme with a blue focusBorder would get blue code and text surfaces).265 // theme with a blue focusBorder would get blue code and text surfaces).
231 const mutedAccentHex = accentColor266 const mutedAccentHex = flattenOver(
232 ? flattenOver({ r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, canvas)267 { r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 },
233 : null;268 canvas,
234 const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex ?? canvasHex);269 );
270 const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex);
235 const sidebarHex =271 const sidebarHex =
236 solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar;272 solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar;
237 const sidebar = hexToRgb(sidebarHex);273 const sidebar = hexToRgb(sidebarHex);
...@@ -258,6 +294,34 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {...@@ -258,6 +294,34 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
258 return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000";294 return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000";
259 };295 };
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
261 const overrides: Partial<Record<ThemeColorRole, string>> = {325 const overrides: Partial<Record<ThemeColorRole, string>> = {
262 canvas: canvasHex,326 canvas: canvasHex,
263 text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"),327 text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"),
...@@ -268,15 +332,14 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {...@@ -268,15 +332,14 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
268 "disabledForeground",332 "disabledForeground",
269 ),333 ),
270 surface: solidOver(canvas, "editorWidget.background") ?? derived.surface,334 surface: solidOver(canvas, "editorWidget.background") ?? derived.surface,
271 surfaceRaised:335 surfaceRaised: surfaceRaisedHex,
272 solidOver(canvas, "editorWidget.background", "dropdown.background") ?? derived.surfaceRaised,
273 surfaceOverlay:336 surfaceOverlay:
274 solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ??337 solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ??
275 derived.surfaceOverlay,338 derived.surfaceOverlay,
276 border:339 border:
277 solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border,340 solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border,
278 input: solidOver(canvas, "input.border", "dropdown.border") ?? derived.input,341 input: inputHex,
279 placeholder: readableOn(canvasHex, derived.placeholder, "input.placeholderForeground"),342 placeholder: readableOn(surfaceRaisedHex, derived.placeholder, "input.placeholderForeground"),
280 error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"),343 error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"),
281 warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"),344 warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"),
282 accentSurface:345 accentSurface:
...@@ -303,23 +366,15 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {...@@ -303,23 +366,15 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
303 terminalScrollbar:366 terminalScrollbar:
304 solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar,367 solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar,
305 };368 };
306 if (accentHex) {369 overrides.accent = accentHex;
307 overrides.accent = accentHex;370 overrides.focus = accentHex;
308 overrides.focus = accentHex;371 overrides.messageAction = actionHex;
309 // The button pair is the closest thing VS Code has to our action color.372 overrides.messageActionForeground = readableOn(
310 const actionHex = solidOver(canvas, "button.background") ?? accentHex;373 actionHex,
311 overrides.messageAction = actionHex;374 derived.messageActionForeground,
312 overrides.messageActionForeground = readableOn(375 "button.foreground",
313 actionHex,376 );
314 derived.messageActionForeground,377 overrides.accentForeground = readableOn(accentHex, derived.accentForeground, "button.foreground");
315 "button.foreground",
316 );
317 overrides.accentForeground = readableOn(
318 accentHex,
319 derived.accentForeground,
320 "button.foreground",
321 );
322 }
323378
324 // Reuse the theme-file parser so ids, names, and color values go through the379 // Reuse the theme-file parser so ids, names, and color values go through the
325 // same validation as a hand-written file.380 // same validation as a hand-written file.