From 3dff1727b1746253583d61ba678414554fe848ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 14:03:28 -0700 Subject: [PATCH] fix(control-ui): give chat links a contrast-safe color token (#123824) chat links used --accent while user bubbles are filled from --accent-subtle (and peer bubbles from a per-sender hue), so links sat on their own hue at 3.95:1 worst case. New --link/--link-hover tokens derive from each palette's audited --accent-hover and clear WCAG AA on every bubble surface and sender hue; the hover opacity fade, which lowered contrast, is replaced by a color shift. --- .../styles/base-theme-contrast.node.test.ts | 270 +++++++++++++++++- ui/src/styles/base.css | 11 + ui/src/styles/chat/text.css | 13 +- ui/src/styles/sidebar-markdown.css | 9 +- 4 files changed, 284 insertions(+), 19 deletions(-) diff --git a/ui/src/styles/base-theme-contrast.node.test.ts b/ui/src/styles/base-theme-contrast.node.test.ts index 927b4bb0e7e4..8da7f2d26878 100644 --- a/ui/src/styles/base-theme-contrast.node.test.ts +++ b/ui/src/styles/base-theme-contrast.node.test.ts @@ -47,7 +47,27 @@ const CODE_CHIP_HOST_SURFACES = ["--card", "--bg"] as const; const CHIP_SURFACE_MIN_STEP = 1.05; const CHIP_BORDER_MIN_STEP = 1.25; +/* + * Link contrast guardrail for painted chat bubbles. + * + * Accent-colored links can collapse into the accent-derived user fill, while + * sender identity tints make the failing hue theme-dependent. Reading both + * sides from the live rules keeps either CSS declaration from drifting. + */ +const CHAT_LINK_RULE = ".chat-text :where(a)"; +const CHAT_LINK_HOVER_RULE = ".chat-text :where(a:hover)"; +const USER_BUBBLE_RULE = ".chat-group.user .chat-bubble"; +const SENDER_TINT_BUBBLE_RULE = ".chat-group.user.chat-group--sender-tint .chat-bubble"; +// Light mode resets both bubble skins back to flat surfaces, and those rules win +// on source order (see the order contract in chat/grouped.css). Asserting the +// dark fills against light palettes would guard a surface nothing paints. +const LIGHT_USER_BUBBLE_RULE = ':root[data-theme-mode="light"] .chat-bubble'; +const LIGHT_SENDER_TINT_BUBBLE_RULE = + ':root[data-theme-mode="light"] .chat-group.user.chat-group--sender-tint .chat-bubble'; + type TokenMap = Map; +type RGB = readonly [red: number, green: number, blue: number]; +type Color = { rgb: RGB; alpha: number }; function parseThemeBlocks(baseCss: string): Map { const blocks = new Map(); @@ -95,22 +115,162 @@ function resolveThemes(blocks: Map): Map { ]); } -function relativeLuminance(hex: string): number { - const [red = 0, green = 0, blue = 0] = [0, 2, 4].map((offset) => { - const channel = Number.parseInt(hex.slice(offset + 1, offset + 3), 16) / 255; - return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; - }); - return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +function parseHex(hex: string): RGB { + const match = hex.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/iu); + if (!match) { + throw new Error(`could not parse hex color "${hex}"`); + } + return [ + Number.parseInt(match[1] ?? "", 16), + Number.parseInt(match[2] ?? "", 16), + Number.parseInt(match[3] ?? "", 16), + ]; } -function contrastRatio(foregroundHex: string, backgroundHex: string): number { +function relativeLuminance(rgb: RGB): number { + const [red, green, blue] = rgb.map((value) => { + const channel = value / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * (red ?? 0) + 0.7152 * (green ?? 0) + 0.0722 * (blue ?? 0); +} + +function contrastRatio(foreground: RGB, background: RGB): number { const [lighter = 0, darker = 0] = [ - relativeLuminance(foregroundHex), - relativeLuminance(backgroundHex), + relativeLuminance(foreground), + relativeLuminance(background), ].toSorted((a, b) => b - a); return (lighter + 0.05) / (darker + 0.05); } +function parseAlpha(value: string | undefined): number { + if (!value) { + return 1; + } + return value.endsWith("%") ? Number.parseFloat(value) / 100 : Number.parseFloat(value); +} + +function hslToRgb(hue: number, saturation: number, lightness: number): RGB { + const normalizedHue = ((hue % 360) + 360) % 360; + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const segment = normalizedHue / 60; + const secondary = chroma * (1 - Math.abs((segment % 2) - 1)); + const [red, green, blue]: RGB = + segment < 1 + ? [chroma, secondary, 0] + : segment < 2 + ? [secondary, chroma, 0] + : segment < 3 + ? [0, chroma, secondary] + : segment < 4 + ? [0, secondary, chroma] + : segment < 5 + ? [secondary, 0, chroma] + : [chroma, 0, secondary]; + const offset = lightness - chroma / 2; + return [(red + offset) * 255, (green + offset) * 255, (blue + offset) * 255]; +} + +function resolveNumber(value: string, tokens: TokenMap): number { + const variable = value.match(/^var\((--[\w-]+)\)$/u)?.[1]; + const resolved = variable ? tokens.get(variable) : value; + if (resolved === undefined || !Number.isFinite(Number.parseFloat(resolved))) { + throw new Error(`could not resolve numeric value "${value}"`); + } + return Number.parseFloat(resolved); +} + +function mixColors(first: Color, firstWeight: number, second: Color): Color { + const secondWeight = 1 - firstWeight; + const alpha = first.alpha * firstWeight + second.alpha * secondWeight; + if (alpha === 0) { + return { rgb: [0, 0, 0], alpha }; + } + // Premultiplied, matching CSS color-mix: a translucent operand contributes in + // proportion to its own alpha, not just its declared weight. + const channel = (index: 0 | 1 | 2): number => + (first.rgb[index] * first.alpha * firstWeight + + second.rgb[index] * second.alpha * secondWeight) / + alpha; + return { rgb: [channel(0), channel(1), channel(2)], alpha }; +} + +function resolveColor(value: string, tokens: TokenMap, resolving = new Set()): Color { + const color = value.trim(); + if (color.startsWith("#")) { + return { rgb: parseHex(color), alpha: 1 }; + } + + const variable = color.match(/^var\((--[\w-]+)\)$/u)?.[1]; + if (variable) { + if (resolving.has(variable)) { + throw new Error(`circular color token "${variable}"`); + } + const resolved = tokens.get(variable); + if (!resolved) { + throw new Error(`could not resolve color token "${variable}"`); + } + return resolveColor(resolved, tokens, new Set(resolving).add(variable)); + } + + const rgb = color.match( + /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+%?))?\s*\)$/u, + ); + if (rgb) { + return { + rgb: [ + Number.parseFloat(rgb[1] ?? ""), + Number.parseFloat(rgb[2] ?? ""), + Number.parseFloat(rgb[3] ?? ""), + ], + alpha: parseAlpha(rgb[4]), + }; + } + + const hsl = color.match( + /^hsl\(\s*(var\(--[\w-]+\)|-?[\d.]+)\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+%?))?\s*\)$/u, + ); + if (hsl) { + return { + rgb: hslToRgb( + resolveNumber(hsl[1] ?? "", tokens), + Number.parseFloat(hsl[2] ?? "") / 100, + Number.parseFloat(hsl[3] ?? "") / 100, + ), + alpha: parseAlpha(hsl[4]), + }; + } + + const mix = color.match( + /^color-mix\(in srgb,\s*(var\(--[\w-]+\))\s+([\d.]+)%,\s*(var\(--[\w-]+\))\s*\)$/u, + ); + if (mix) { + return mixColors( + resolveColor(mix[1] ?? "", tokens, resolving), + Number.parseFloat(mix[2] ?? "") / 100, + resolveColor(mix[3] ?? "", tokens, resolving), + ); + } + + throw new Error(`could not resolve color "${value}"`); +} + +function composite(color: Color, background: RGB): RGB { + return [ + color.rgb[0] * color.alpha + background[0] * (1 - color.alpha), + color.rgb[1] * color.alpha + background[1] * (1 - color.alpha), + color.rgb[2] * color.alpha + background[2] * (1 - color.alpha), + ]; +} + +function resolveOpaqueColor(value: string, tokens: TokenMap): RGB { + const color = resolveColor(value, tokens); + if (color.alpha !== 1) { + throw new Error(`expected opaque color "${value}"`); + } + return color.rgb; +} + /** Read the surface/border tokens the shipped code-chip rule actually paints. */ function readCodeChipTokens(chatTextCss: string): { surface: string; border: string } { const rule = chatTextCss.split(CODE_CHIP_RULE)[1]?.split("}")[0] ?? ""; @@ -122,6 +282,46 @@ function readCodeChipTokens(chatTextCss: string): { surface: string; border: str return { surface, border }; } +function readRuleBody(css: string, selector: string): string { + const body = css.split(`${selector} {`)[1]?.split("}")[0]; + if (body === undefined) { + throw new Error(`could not read rule "${selector}"`); + } + return body; +} + +function readChatLinkTokens(chatTextCss: string): { link: string; hover: string } { + const link = readRuleBody(chatTextCss, CHAT_LINK_RULE).match(/color:\s*var\((--[\w-]+)\)/u)?.[1]; + const hover = readRuleBody(chatTextCss, CHAT_LINK_HOVER_RULE).match( + /color:\s*var\((--[\w-]+)\)/u, + )?.[1]; + if (!link) { + throw new Error(`could not read link token from "${CHAT_LINK_RULE}"`); + } + return { link, hover: hover ?? link }; +} + +function readBubbleBackgrounds(groupedCss: string): { + user: string; + lightUser: string; + senderTint: string; + lightSenderTint: string; +} { + const readBackground = (selector: string): string => { + const background = readRuleBody(groupedCss, selector).match(/background:\s*([^;]+);/u)?.[1]; + if (!background) { + throw new Error(`could not read bubble background from "${selector}"`); + } + return background.trim(); + }; + return { + user: readBackground(USER_BUBBLE_RULE), + lightUser: readBackground(LIGHT_USER_BUBBLE_RULE), + senderTint: readBackground(SENDER_TINT_BUBBLE_RULE), + lightSenderTint: readBackground(LIGHT_SENDER_TINT_BUBBLE_RULE), + }; +} + describe("Control UI theme contrast", () => { const baseCss = fs.readFileSync(path.join(stylesDir, "base.css"), "utf8"); const themes = resolveThemes(parseThemeBlocks(baseCss)); @@ -139,7 +339,7 @@ describe("Control UI theme contrast", () => { if (!background?.startsWith("#")) { continue; } - const ratio = contrastRatio(foreground, background); + const ratio = contrastRatio(parseHex(foreground), parseHex(background)); if (ratio < AA_NORMAL_TEXT_MIN) { failures.push( `${themeName}: ${textToken} ${foreground} on ${surfaceToken} ${background} = ${ratio.toFixed(2)}:1 (< ${AA_NORMAL_TEXT_MIN}:1)`, @@ -165,8 +365,8 @@ describe("Control UI theme contrast", () => { if (!host?.startsWith("#")) { continue; } - const surfaceStep = contrastRatio(surface ?? "", host); - const borderStep = contrastRatio(border ?? "", host); + const surfaceStep = contrastRatio(parseHex(surface ?? ""), parseHex(host)); + const borderStep = contrastRatio(parseHex(border ?? ""), parseHex(host)); if (surfaceStep < CHIP_SURFACE_MIN_STEP) { failures.push( `${themeName}: chip ${chip.surface} ${surface} on ${hostToken} ${host} = ${surfaceStep.toFixed(2)}:1 (< ${CHIP_SURFACE_MIN_STEP}:1)`, @@ -181,4 +381,50 @@ describe("Control UI theme contrast", () => { } expect(failures).toEqual([]); }); + + it("keeps chat links at WCAG AA on every bubble surface", () => { + const chatTextCss = fs.readFileSync(path.join(stylesDir, "chat", "text.css"), "utf8"); + const groupedCss = fs.readFileSync(path.join(stylesDir, "chat", "grouped.css"), "utf8"); + const linkTokens = readChatLinkTokens(chatTextCss); + const bubbleBackgrounds = readBubbleBackgrounds(groupedCss); + const failures: string[] = []; + for (const [themeName, tokens] of themes) { + const page = resolveOpaqueColor("var(--bg)", tokens); + const isLight = themeName === "light" || themeName.endsWith("-light"); + const userFill = isLight ? bubbleBackgrounds.lightUser : bubbleBackgrounds.user; + const senderTint = isLight ? bubbleBackgrounds.lightSenderTint : bubbleBackgrounds.senderTint; + const userBubble = composite(resolveColor(userFill, tokens), page); + + for (const [state, token] of [ + ["link", linkTokens.link], + ["link hover", linkTokens.hover], + ] as const) { + const foreground = resolveColor(`var(${token})`, tokens); + const userRatio = contrastRatio(composite(foreground, userBubble), userBubble); + if (userRatio < AA_NORMAL_TEXT_MIN) { + failures.push( + `${themeName}: ${state} ${token} on user bubble ${userFill} = ${userRatio.toFixed(2)}:1 (< ${AA_NORMAL_TEXT_MIN}:1)`, + ); + } + + let worstRatio = Number.POSITIVE_INFINITY; + let worstHue = 0; + for (let hue = 0; hue < 360; hue += 1) { + const hueTokens = new Map(tokens).set("--chat-sender-hue", String(hue)); + const bubble = composite(resolveColor(senderTint, hueTokens), page); + const ratio = contrastRatio(composite(foreground, bubble), bubble); + if (ratio < worstRatio) { + worstRatio = ratio; + worstHue = hue; + } + } + if (worstRatio < AA_NORMAL_TEXT_MIN) { + failures.push( + `${themeName}: ${state} ${token} on sender-tinted bubble ${senderTint} at hue ${worstHue} = ${worstRatio.toFixed(2)}:1 (< ${AA_NORMAL_TEXT_MIN}:1)`, + ); + } + } + } + expect(failures).toEqual([]); + }); }); diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 2dcbc3c59d82..623475054213 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -80,6 +80,17 @@ --accent-subtle: rgba(255, 92, 92, 0.1); --accent-foreground: #fafafa; --accent-glow: rgba(255, 92, 92, 0.2); + + /* Link text keeps the accent hue but cannot be --accent: chat paints user + bubbles from that same token, so a link there fights its own background. + Derived, not per-theme — --accent-hover is every palette's audited lifted + accent and custom themes derive it too, and the extra 15% of --text buys + the margin the sender-tinted peer bubbles need (the lightest tint hue on + openknot sits at 4.3:1 against bare --accent-hover). Both steps move toward + the text color, which raises contrast in light and dark alike, so hover is + feedback that never costs readability. */ + --link: color-mix(in srgb, var(--accent-hover) 85%, var(--text)); + --link-hover: color-mix(in srgb, var(--link) 82%, var(--text)); --selection-bg: #005fcc; --selection-fg: #ffffff; --primary: #d13c3c; diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 3020570ca3c0..bde0b747959f 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -269,14 +269,19 @@ } } +/* --link, not --accent: user bubbles are filled with --accent-subtle (and peer + bubbles with the sender tint), so an accent-colored link sits on its own hue + and drops to 4.1:1 on the chocolate palette — 3.95:1 at the worst sender hue — + while the body text beside it holds 9:1. Hover shifts color instead of fading + opacity, which composited the link toward its background and cost another 1.1. */ .chat-text :where(a) { - color: var(--accent); + color: var(--link); text-decoration: underline; text-underline-offset: 2px; } .chat-text :where(a:hover) { - opacity: 0.8; + color: var(--link-hover); } /* GitHub links carry the brand mark so a bare URL and a "[#3434]" shorthand read @@ -325,7 +330,7 @@ purpose: each renderer's own :where(a) and code rules would otherwise win on source order, and sidebar-markdown.css is imported after this file. */ :is(.chat-text, .sidebar-markdown) a.markdown-file-link { - color: var(--accent); + color: var(--link); /* File links are href-less anchors (driven by data-file-path), so the UA link pointer does not apply; they are content links and keep the hand. */ cursor: pointer; @@ -460,7 +465,7 @@ rather than resetting them here, so no theme override has to be out-specified; this rule only has to undo the separate mono-font declarations. */ :is(.chat-text, .sidebar-markdown) a.markdown-file-link > code { - color: var(--accent); + color: var(--link); font-family: inherit; font-size: inherit; } diff --git a/ui/src/styles/sidebar-markdown.css b/ui/src/styles/sidebar-markdown.css index 9358681eb14f..8c8e98f358a4 100644 --- a/ui/src/styles/sidebar-markdown.css +++ b/ui/src/styles/sidebar-markdown.css @@ -86,16 +86,19 @@ vertical-align: middle; } +/* Same link token as the message renderer: file links are styled jointly with + .chat-text (see chat/text.css), so a panel that kept --accent would show two + link colors side by side. */ .sidebar-markdown :where(a) { - color: var(--accent); + color: var(--link); text-decoration: underline; - text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); + text-decoration-color: color-mix(in srgb, var(--link) 40%, transparent); text-underline-offset: 2px; transition: text-decoration-color var(--duration-fast) ease; } .sidebar-markdown :where(a:hover) { - text-decoration-color: var(--accent); + text-decoration-color: var(--link); } .sidebar-markdown code {