mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-14 14:43:16 -06:00
fix(ui): make chat inline code readable on dark themes (#121376)
Every dark palette defines --secondary with the same hex as --card, so the markdown code chip painted with --secondary collapsed into the surface it sat on (1.00:1 against a user bubble, 1.06-1.08:1 against the flat assistant column) and --border was too close to the background to draw an edge. Light mode never showed the bug because it overrode both properties. Promote the light override into the single canonical rule: code chips and code blocks paint --bg-muted with a --border-strong edge in every theme, and the mode-specific overrides (including the raw rgba border) are deleted. Also fixes the theme-contrast guard, which merged repeated `:root` blocks by overwriting instead of merging, so the default `dark` theme resolved to an empty token map and skipped every assertion. A new case reads the chip tokens out of the shipped rule and asserts surface and border separation from --card and --bg on all six themes.
This commit is contained in:
committed by
GitHub
parent
4fe078fcd8
commit
bb02fc53ea
@@ -31,6 +31,20 @@ const SURFACE_TOKENS = ["--bg", "--bg-elevated", "--bg-muted", "--card", "--pane
|
||||
|
||||
const AA_NORMAL_TEXT_MIN = 4.5;
|
||||
|
||||
/*
|
||||
* Separation guardrail for the markdown code chip.
|
||||
*
|
||||
* Text contrast was never the failure mode here: the chip surface itself
|
||||
* collapsed. Every dark palette sets `--secondary` to the same hex as `--card`,
|
||||
* so a chip painted with it was invisible inside a user bubble while light mode
|
||||
* (which overrode the surface) looked correct. The chip tokens are read out of
|
||||
* the live rule so swapping them back for a collapsing pair fails here.
|
||||
*/
|
||||
const CODE_CHIP_RULE = ".chat-text :where(:not(pre) > code)";
|
||||
const CODE_CHIP_HOST_SURFACES = ["--card", "--bg"] as const;
|
||||
const CHIP_SURFACE_MIN_STEP = 1.05;
|
||||
const CHIP_BORDER_MIN_STEP = 1.25;
|
||||
|
||||
type TokenMap = Map<string, string>;
|
||||
|
||||
function parseThemeBlocks(baseCss: string): Map<string, TokenMap> {
|
||||
@@ -39,7 +53,10 @@ function parseThemeBlocks(baseCss: string): Map<string, TokenMap> {
|
||||
for (const match of baseCss.matchAll(blockPattern)) {
|
||||
const selector = match[1] ?? "";
|
||||
const body = match[2] ?? "";
|
||||
const tokens: TokenMap = new Map();
|
||||
// base.css declares `:root` more than once (palette, then the standalone
|
||||
// --cursor-action blocks). Merging keeps the palette; overwriting made the
|
||||
// default `dark` theme resolve to an empty map and skip every assertion.
|
||||
const tokens: TokenMap = blocks.get(selector) ?? new Map();
|
||||
for (const line of body.split("\n")) {
|
||||
const declaration = line.match(/^\s*(--[\w-]+)\s*:\s*([^;]+);/);
|
||||
const name = declaration?.[1];
|
||||
@@ -92,6 +109,17 @@ function contrastRatio(foregroundHex: string, backgroundHex: string): number {
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/** 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] ?? "";
|
||||
const surface = rule.match(/background:\s*var\((--[\w-]+)\)/u)?.[1];
|
||||
const border = rule.match(/border:[^;]*var\((--[\w-]+)\)/u)?.[1];
|
||||
if (!surface || !border) {
|
||||
throw new Error(`could not read chip tokens from "${CODE_CHIP_RULE}"`);
|
||||
}
|
||||
return { surface, border };
|
||||
}
|
||||
|
||||
describe("Control UI theme contrast", () => {
|
||||
const baseCss = fs.readFileSync(path.join(stylesDir, "base.css"), "utf8");
|
||||
const themes = resolveThemes(parseThemeBlocks(baseCss));
|
||||
@@ -120,4 +148,35 @@ describe("Control UI theme contrast", () => {
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the markdown code chip separated from every surface it sits on", () => {
|
||||
const chatTextCss = fs.readFileSync(path.join(stylesDir, "chat", "text.css"), "utf8");
|
||||
const chip = readCodeChipTokens(chatTextCss);
|
||||
const failures: string[] = [];
|
||||
for (const [themeName, tokens] of themes) {
|
||||
const surface = tokens.get(chip.surface);
|
||||
const border = tokens.get(chip.border);
|
||||
expect(surface, `${themeName}: ${chip.surface} is not a hex token`).toMatch(/^#/u);
|
||||
expect(border, `${themeName}: ${chip.border} is not a hex token`).toMatch(/^#/u);
|
||||
for (const hostToken of CODE_CHIP_HOST_SURFACES) {
|
||||
const host = tokens.get(hostToken);
|
||||
if (!host?.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const surfaceStep = contrastRatio(surface ?? "", host);
|
||||
const borderStep = contrastRatio(border ?? "", 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)`,
|
||||
);
|
||||
}
|
||||
if (borderStep < CHIP_BORDER_MIN_STEP) {
|
||||
failures.push(
|
||||
`${themeName}: chip border ${chip.border} ${border} on ${hostToken} ${host} = ${borderStep.toFixed(2)}:1 (< ${CHIP_BORDER_MIN_STEP}:1)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,18 +194,21 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Code surfaces must lift off the host bubble in every theme. --secondary equals
|
||||
--card on every dark palette, so chips painted with it vanished inside user
|
||||
bubbles; --bg-muted/--border-strong separate in both modes without an override. */
|
||||
.chat-text :where(:not(pre) > code) {
|
||||
background: var(--secondary);
|
||||
background: var(--bg-muted);
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-strong);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-text :where(pre) {
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-muted);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 0.75em;
|
||||
padding: 10px 12px;
|
||||
@@ -270,16 +273,6 @@
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme-mode="light"] .chat-text :where(:not(pre) > code) {
|
||||
background: var(--bg-muted);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme-mode="light"] .chat-text :where(pre) {
|
||||
background: var(--bg-muted);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chat-text :where(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
Reference in New Issue
Block a user