fix(editor): keep highlight tokens readable inside code blocks

Syntax highlighting handed the code-block surface to atom-one-dark, which is
#282c34 in both themes, but the `{{.Var}}` / `<tag>` view decorations kept
their theme tokens — dark ink meant for a light ground. Measured on the stand:
a tag inside a fence sits at 1.16:1 in light theme (a variable at 1.72), and
dark had slipped to 4.05, below AA. Outside a fence both are unaffected.
Re-point the two tokens at the dark ground inside `pre`, where they measure
5.77 / 5.30 in both themes.

The contrast gate could not see this: it mounts probes on a synthetic --card
surface and composited only the probe's immediate parent, so a span inside a
transparent `code` measured against nothing. Walk to the first opaque ancestor
instead, and pin the real rendered spans in the prompt-detail editor — the
cassette's bash fence now carries a tag as well as a variable. Both new rows
fail on the previous CSS with exactly the numbers above.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-25 22:36:23 +07:00
co-authored by Claude Opus 4.8
parent a7c0c19b53
commit a6fc854e0b
4 changed files with 76 additions and 13 deletions
+29 -10
View File
@@ -83,11 +83,14 @@ export const mountEditorProbes = async (page: Page, probes: Record<string, Edito
};
export const measureContrast = async (page: Page, probe: string): Promise<number> =>
page.evaluate((name) => {
const element = document.querySelector<HTMLElement>(`[data-contrast="${name}"]`);
measureContrastAt(page, `[data-contrast="${probe}"]`);
export const measureContrastAt = async (page: Page, selector: string): Promise<number> =>
page.evaluate((target) => {
const element = document.querySelector<HTMLElement>(target);
if (!element?.parentElement) {
throw new Error(`contrast probe "${name}" is not mounted`);
throw new Error(`contrast target "${target}" is not mounted`);
}
const canvas = document.createElement('canvas');
@@ -138,15 +141,31 @@ export const measureContrast = async (page: Page, probe: string): Promise<number
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
};
// The text goes on top of the same stack, not on its own: `paint` starts from transparent,
// where a translucent colour keeps its opaque base and reads as a contrast it does not have.
const ground = [
getComputedStyle(element.parentElement).backgroundColor,
getComputedStyle(element).backgroundColor,
];
// Walk to the first opaque ancestor, not just the parent: a probe inside a code block sits on
// `pre.hljs` through a transparent `code`, and stopping at the parent would measure it against
// nothing. `paint` starts from transparent, so a translucent layer with no opaque base under it
// keeps its own colour and reads as a contrast it does not have.
const stack: string[] = [];
for (let node: HTMLElement | null = element.parentElement; node; node = node.parentElement) {
const background = getComputedStyle(node).backgroundColor;
const alpha = Number(background.match(/rgba?\([^)]*,\s*([\d.]+)\s*\)/)?.[1] ?? '1');
if (alpha === 0) {
continue;
}
stack.unshift(background);
if (alpha === 1) {
break;
}
}
const ground = [...stack, getComputedStyle(element).backgroundColor];
const surface = paint(...ground);
const label = paint(...ground, getComputedStyle(element).color);
const [high = 0, low = 0] = [luminance(label), luminance(surface)].sort((a, b) => b - a);
return (high + 0.05) / (low + 0.05);
}, probe);
}, selector);
@@ -32,7 +32,7 @@ export const RICH_PROMPT_TEMPLATE = [
'- report every finding with evidence',
'',
'```bash',
'nmap -sV {{.Target}}',
'nmap -sV {{.Target}} --script <default>',
'```',
'',
'| Field | Value |',
+41 -1
View File
@@ -8,8 +8,15 @@ import { buttonVariants } from '@/components/ui/button';
import type { EditorProbe } from '../../helpers/contrast.ts';
import { expect, test } from '../../fixtures/test.ts';
import { AA_NORMAL, measureContrast, mountContrastProbes, mountEditorProbes } from '../../helpers/contrast.ts';
import {
AA_NORMAL,
measureContrast,
measureContrastAt,
mountContrastProbes,
mountEditorProbes,
} from '../../helpers/contrast.ts';
import { flowsCassette } from '../../mocks/cassettes/flows.ts';
import { PROMPT_DETAIL_AGENT, promptDetailCassette } from '../../mocks/cassettes/settings-prompts.ts';
const EDITOR_PROBES = {
'editor-accent': { tag: 'a' },
@@ -113,4 +120,37 @@ for (const theme of THEMES) {
}
});
});
test.describe(`contrast in a code block (${theme})`, { tag: '@cross' }, () => {
test.use({ cassette: promptDetailCassette() });
if (theme === 'dark') {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => window.localStorage.setItem('theme', 'dark'));
});
}
test('editor highlight tokens clear AA inside a fence', async ({ page }) => {
await page.goto(`/settings/prompts/${PROMPT_DETAIL_AGENT}`);
const fence = page.locator('.tiptap-content .ProseMirror pre');
await expect(fence).toBeVisible();
await expect(page.locator('html')).toHaveClass(theme === 'dark' ? /dark/ : /light/);
// The atom-one-dark stylesheet ships with the editor chunk. Measuring before it lands would
// put the probes on the page ground and pass on a surface no user ever sees.
await expect(fence).toHaveCSS('background-color', 'rgb(40, 44, 52)');
for (const token of ['variable', 'tag'] as const) {
await expect(fence.locator(`.template-${token}`).first()).toBeVisible();
expect
.soft(
await measureContrastAt(page, `.tiptap-content .ProseMirror pre .template-${token}`),
`editor-${token} inside a code block (${theme})`,
)
.toBeGreaterThanOrEqual(AA_NORMAL);
}
});
});
}
+5 -1
View File
@@ -634,8 +634,12 @@
font-size: 0.875em;
}
/* No background/colour here — the .hljs highlight.js theme (imported in markdown-editor.tsx) supplies them. */
/* No background/colour here — the .hljs highlight.js theme (imported in markdown-editor.tsx) supplies them.
That surface is dark in both themes, so the highlight tokens are re-pointed at values that clear AA on it. */
.tiptap-content .ProseMirror pre {
--editor-variable: oklch(0.76 0.12 155);
--editor-tag: oklch(0.76 0.13 300);
position: relative;
padding: 0.75rem 1rem;
border-radius: calc(var(--radius) - 2px);