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.
This commit is contained in:
Peter Steinberger
2026-08-14 14:03:28 -07:00
committed by GitHub
parent fe6b0237d8
commit 3dff1727b1
4 changed files with 284 additions and 19 deletions
+258 -12
View File
@@ -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<string, string>;
type RGB = readonly [red: number, green: number, blue: number];
type Color = { rgb: RGB; alpha: number };
function parseThemeBlocks(baseCss: string): Map<string, TokenMap> {
const blocks = new Map<string, TokenMap>();
@@ -95,22 +115,162 @@ function resolveThemes(blocks: Map<string, TokenMap>): Map<string, TokenMap> {
]);
}
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<string>()): 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([]);
});
});
+11
View File
@@ -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;
+9 -4
View File
@@ -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;
}
+6 -3
View File
@@ -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 {