mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-17 08:02:12 -06:00
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:
committed by
GitHub
parent
fe6b0237d8
commit
3dff1727b1
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user