From 64b8b5cea95d81c01fd1692ad3e129f4e362b3aa Mon Sep 17 00:00:00 2001 From: kkmihai Date: Mon, 2 Nov 2026 14:09:27 +0200 Subject: [PATCH] fix(code-insight): keep usage highlights readable (#855) Usage previews rendered the matched occurrence through tokenSpans, so the token colour rules outranked the inherited --fg-search-match and left syntax colours on the search-match background (about 1.1:1 in Islands Light). Render the match as plain text so it takes the search-match foreground; syntax colours stay on the surrounding code. Covered by unit tests and a Playwright contrast check across light, dark, high-contrast and mixed editor schemes. Fixes #851 Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com> --- e2e/usage-highlight-contrast.spec.ts | 81 +++++++++++++++++++ src/features/code-insight/UsagesView.tsx | 2 +- .../code-insight/usagePreview.test.tsx | 7 +- 3 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 e2e/usage-highlight-contrast.spec.ts diff --git a/e2e/usage-highlight-contrast.spec.ts b/e2e/usage-highlight-contrast.spec.ts new file mode 100644 index 000000000..c10e212ea --- /dev/null +++ b/e2e/usage-highlight-contrast.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "./fixtures"; + +const themes = [ + { theme: "light", scheme: "theme" }, + { theme: "dark", scheme: "theme" }, + { theme: "dark-hc", scheme: "theme" }, + { theme: "light", scheme: "dark" }, + { theme: "dark", scheme: "light" }, +] as const; + +for (const { theme, scheme } of themes) { + test(`usage highlights have readable text with ${theme} theme and ${scheme} scheme`, async ({ + workbench: page, + }, info) => { + await page.evaluate( + async ({ theme, scheme }) => { + const themePath = "/src/workbench/theme.ts"; + const schemePath = "/src/platform/colorScheme.ts"; + const usagesPath = "/src/features/code-insight/usages.ts"; + const { applyThemeAttributes } = await import(themePath); + const { schemeCss } = await import(schemePath); + const { useUsagesPopup } = await import(usagesPath); + applyThemeAttributes(theme); + const style = document.createElement("style"); + style.textContent = schemeCss(scheme); + document.head.append(style); + const symbol = "trainingSessionFetch"; + const texts = [ + `export function ${symbol}() {}`, + `import { ${symbol} } from './api';`, + `// Call ${symbol} here`, + `const name = '${symbol}';`, + ]; + useUsagesPopup.setState({ + open: true, + anchor: { x: 100, y: 100 }, + symbol, + groups: texts.map((text, index) => ({ + path: `/contrast/example-${index}.ts`, + usages: [ + { + path: `/contrast/example-${index}.ts`, + line: 0, + col: text.indexOf(symbol), + endCol: text.indexOf(symbol) + symbol.length, + text, + }, + ], + })), + note: null, + }); + }, + { theme, scheme }, + ); + const popup = page.getByRole("dialog", { name: "Usages", exact: true }); + await expect(popup.locator(".tok-comment").first()).toBeVisible(); + await expect(popup.locator("mark")).toHaveCount(4); + const ratios = await popup.locator("mark").evaluateAll((marks) => { + const luminance = (color: string) => { + const channels = color + .match(/[\d.]+/g)! + .slice(0, 3) + .map(Number) + .map((value) => { + const channel = value / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722; + }; + return marks.flatMap((mark) => { + const background = luminance(getComputedStyle(mark).backgroundColor); + return [mark, ...mark.querySelectorAll("*")].map((node) => { + const foreground = luminance(getComputedStyle(node).color); + return (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05); + }); + }); + }); + for (const ratio of ratios) expect(ratio).toBeGreaterThanOrEqual(4.5); + await popup.screenshot({ path: info.outputPath("usage-highlights.png") }); + }); +} diff --git a/src/features/code-insight/UsagesView.tsx b/src/features/code-insight/UsagesView.tsx index ca7ea44c0..0f7795054 100644 --- a/src/features/code-insight/UsagesView.tsx +++ b/src/features/code-insight/UsagesView.tsx @@ -54,7 +54,7 @@ export function UsagePreview({ usage }: { usage: Usage }) { return ( {tokenSpans(text, 0, from, tokens, "b:")} - {tokenSpans(text, from, to, tokens, "m:")} + {text.slice(from, to)} {tokenSpans(text, to, text.length, tokens, "a:")} ); diff --git a/src/features/code-insight/usagePreview.test.tsx b/src/features/code-insight/usagePreview.test.tsx index 7c276925d..79e3b8c23 100644 --- a/src/features/code-insight/usagePreview.test.tsx +++ b/src/features/code-insight/usagePreview.test.tsx @@ -17,14 +17,17 @@ describe("UsagePreview", () => { ); await waitFor(() => expect(container.querySelector(".tok-keyword")?.textContent).toBe("const")); expect(container.querySelector("mark")?.textContent).toBe("foo"); + expect(container.querySelector("mark")?.childElementCount).toBe(0); expect(container.textContent).toBe("const x = foo();"); }); - it("colours a usage inside a doc comment as a comment", async () => { + it("keeps comment colours outside the match and the search foreground inside it", async () => { const { container } = render( , ); - await waitFor(() => expect(container.querySelector("mark .tok-comment")?.textContent).toBe("foo")); + await waitFor(() => expect(container.querySelector(".tok-comment")?.textContent).toBe("* Uses ")); + expect(container.querySelector("mark")?.textContent).toBe("foo"); + expect(container.querySelector("mark")?.childElementCount).toBe(0); }); });