fix: harden callout contrast and code wrapping

This commit is contained in:
Peter Li 2026-07-17 09:55:33 +08:00
parent afae9e9a5f
commit 13b81bf849
10 changed files with 236 additions and 10 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -46,8 +46,86 @@ export function contrastRatio(foreground, background) {
return (lighter + 0.05) / (darker + 0.05);
}
function mixHex(foreground, background, foregroundWeight) {
const channels = rgbChannels(foreground).map((channel, index) =>
Math.round(
channel * foregroundWeight +
rgbChannels(background)[index] * (1 - foregroundWeight),
),
);
return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
}
const CALLOUT_TYPE_COLORS = {
light: {
note: "#086ddd",
warning: "#ec7500",
error: "#e93147",
example: "#7852ee",
quote: "#9e9e9e",
tip: "#00bfbc",
},
dark: {
note: "#027aff",
warning: "#e9973f",
error: "#fb464c",
example: "#a882ff",
quote: "#9e9e9e",
tip: "#53dfdd",
},
};
const CALLOUT_THEME_CONFIG = {
light: {
base: "#f8fafc",
normal: "#202936",
backgroundOpacity: 0.08,
titleWeight: 0.52,
bodyWeight: 0.5,
},
dark: {
base: "#17191c",
normal: "#e7ebf0",
backgroundOpacity: 0.12,
titleWeight: 0.84,
bodyWeight: 0.72,
},
};
export const CALLOUT_CONTRAST_PAIRS = Object.entries(CALLOUT_TYPE_COLORS)
.flatMap(([theme, types]) => {
const config = CALLOUT_THEME_CONFIG[theme];
return Object.entries(types).flatMap(([type, semantic]) => {
const background = mixHex(
semantic,
config.base,
config.backgroundOpacity,
);
return [
[
`${theme} callout ${type} title`,
mixHex(semantic, config.normal, config.titleWeight),
background,
],
[
`${theme} callout ${type} content`,
mixHex(semantic, config.normal, config.bodyWeight),
background,
],
];
});
});
export const ALL_CONTRAST_PAIRS = [
...CORE_CONTRAST_PAIRS,
...CALLOUT_CONTRAST_PAIRS,
];
export function runContrastCli(
pairs = CORE_CONTRAST_PAIRS,
pairs = ALL_CONTRAST_PAIRS,
writeLine = (line) => console.log(line),
) {
let failed = false;

View file

@ -17,7 +17,7 @@ body {
);
color: color-mix(
in srgb,
rgb(var(--callout-color)) 72%,
rgb(var(--callout-color)) var(--aera-callout-body-semantic-weight),
var(--text-normal)
);
border: 0;
@ -27,7 +27,7 @@ body {
padding-inline-end: var(--size-4-12);
color: color-mix(
in srgb,
rgb(var(--callout-color)) 84%,
rgb(var(--callout-color)) var(--aera-callout-title-semantic-weight),
var(--text-normal)
);
}

View file

@ -36,3 +36,11 @@ body {
:where(.markdown-source-view.mod-cm6 .HyperMD-codeblock) {
@include monokai-block;
}
.markdown-source-view.mod-cm6 :where(.HyperMD-codeblock) {
white-space: pre;
word-break: normal;
overflow-wrap: normal;
width: max-content;
min-width: 100%;
}

View file

@ -29,6 +29,8 @@
--aera-inline-code-background: #d9dee5;
--aera-inline-code-color: #566273;
--aera-callout-background-opacity: 0.08;
--aera-callout-title-semantic-weight: 52%;
--aera-callout-body-semantic-weight: 50%;
--aera-quote-background: #f1f3f6;
--aera-quote-fold: #d9dee5;
}
@ -64,6 +66,8 @@
--aera-inline-code-background: #2a2f36;
--aera-inline-code-color: #b8c0cc;
--aera-callout-background-opacity: 0.12;
--aera-callout-title-semantic-weight: 84%;
--aera-callout-body-semantic-weight: 72%;
--aera-quote-background: #22262c;
--aera-quote-fold: #3a414a;
}

View file

@ -24,6 +24,7 @@ const allowedSelectors = new Set([
":where(.markdown-rendered pre:not(.frontmatter))",
":where(.markdown-rendered pre:not(.frontmatter)) code[class*=language-]",
":where(.markdown-source-view.mod-cm6 .HyperMD-codeblock)",
".markdown-source-view.mod-cm6 :where(.HyperMD-codeblock)",
".markdown-rendered blockquote",
".markdown-rendered blockquote:not(blockquote blockquote)::before",
".markdown-rendered blockquote:not(blockquote blockquote)::after",
@ -123,7 +124,7 @@ test("uses a borderless semantic callout surface", () => {
"background-color":
"rgba(var(--callout-color), var(--aera-callout-background-opacity))",
color:
"color-mix(in srgb, rgb(var(--callout-color)) 72%, var(--text-normal))",
"color-mix(in srgb, rgb(var(--callout-color)) var(--aera-callout-body-semantic-weight), var(--text-normal))",
border: "0",
});
});
@ -136,7 +137,7 @@ test("keeps callout text clear of the watermark", () => {
assert.deepEqual(Object.fromEntries(title), {
"padding-inline-end": "var(--size-4-12)",
color:
"color-mix(in srgb, rgb(var(--callout-color)) 84%, var(--text-normal))",
"color-mix(in srgb, rgb(var(--callout-color)) var(--aera-callout-title-semantic-weight), var(--text-normal))",
});
assert.deepEqual(Object.fromEntries(content), {
"padding-inline-end": "var(--size-4-12)",

View file

@ -227,3 +227,18 @@ test("styles CM6 code block lines with direct Monokai declarations", () => {
monokaiDeclarations,
);
});
test("keeps CM6 fenced code lines on one horizontal line", () => {
assert.deepEqual(
directDeclarations(
".markdown-source-view.mod-cm6 :where(.HyperMD-codeblock)",
),
[
["white-space", "pre"],
["word-break", "normal"],
["overflow-wrap", "normal"],
["width", "max-content"],
["min-width", "100%"],
],
);
});

View file

@ -8,6 +8,7 @@ import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CALLOUT_CONTRAST_PAIRS,
CORE_CONTRAST_PAIRS,
contrastRatio,
relativeLuminance,
@ -73,6 +74,8 @@ const expectedColors = {
"--aera-inline-code-background": "#d9dee5",
"--aera-inline-code-color": "#566273",
"--aera-callout-background-opacity": "0.08",
"--aera-callout-title-semantic-weight": "52%",
"--aera-callout-body-semantic-weight": "50%",
"--aera-quote-background": "#f1f3f6",
"--aera-quote-fold": "#d9dee5",
},
@ -105,11 +108,79 @@ const expectedColors = {
"--aera-inline-code-background": "#2a2f36",
"--aera-inline-code-color": "#b8c0cc",
"--aera-callout-background-opacity": "0.12",
"--aera-callout-title-semantic-weight": "84%",
"--aera-callout-body-semantic-weight": "72%",
"--aera-quote-background": "#22262c",
"--aera-quote-fold": "#3a414a",
},
};
const calloutTypeColors = {
".theme-light": {
note: [8, 109, 221],
warning: [236, 117, 0],
error: [233, 49, 71],
example: [120, 82, 238],
quote: [158, 158, 158],
tip: [0, 191, 188],
},
".theme-dark": {
note: [2, 122, 255],
warning: [233, 151, 63],
error: [251, 70, 76],
example: [168, 130, 255],
quote: [158, 158, 158],
tip: [83, 223, 221],
},
};
function hexChannels(hex) {
return [1, 3, 5].map((offset) =>
Number.parseInt(hex.slice(offset, offset + 2), 16),
);
}
function mixChannels(foreground, background, foregroundWeight) {
return foreground.map(
(channel, index) =>
channel * foregroundWeight + background[index] * (1 - foregroundWeight),
);
}
function channelLuminance(channel) {
const srgb = channel / 255;
return srgb <= 0.04045
? srgb / 12.92
: ((srgb + 0.055) / 1.055) ** 2.4;
}
function channelContrastRatio(foreground, background) {
const luminance = (channels) => {
const [red, green, blue] = channels.map(channelLuminance);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
const foregroundLuminance = luminance(foreground);
const backgroundLuminance = luminance(background);
return (
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
(Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
);
}
function calloutSemanticWeight(colorDeclaration, themeDeclarations) {
const literal = colorDeclaration.match(
/rgb\(var\(--callout-color\)\) ([0-9.]+)%/,
);
if (literal) return Number(literal[1]) / 100;
const variable = colorDeclaration.match(
/rgb\(var\(--callout-color\)\) var\((--[^)]+)\)/,
);
assert.ok(variable, colorDeclaration);
return Number.parseFloat(themeDeclarations.get(variable[1])) / 100;
}
for (const [selector, expected] of Object.entries(expectedColors)) {
test(`${selector} defines the complete Aera color foundation`, () => {
const declarations = declarationsFor(css, selector);
@ -120,6 +191,36 @@ for (const [selector, expected] of Object.entries(expectedColors)) {
});
}
test("semantic callout titles and content meet WCAG AA in both themes", () => {
const roles = [
["title", declarationsFor(css, ".callout-title").get("color")],
["content", declarationsFor(css, ".callout").get("color")],
];
for (const [themeSelector, types] of Object.entries(calloutTypeColors)) {
const theme = declarationsFor(css, themeSelector);
const base = hexChannels(theme.get("--color-base-00"));
const normal = hexChannels(theme.get("--color-base-100"));
const backgroundOpacity = Number.parseFloat(
theme.get("--aera-callout-background-opacity"),
);
for (const [type, semantic] of Object.entries(types)) {
const background = mixChannels(semantic, base, backgroundOpacity);
for (const [role, colorDeclaration] of roles) {
const weight = calloutSemanticWeight(colorDeclaration, theme);
const foreground = mixChannels(semantic, normal, weight);
const ratio = channelContrastRatio(foreground, background);
assert.ok(
ratio >= 4.5,
`${themeSelector} ${type} ${role} is ${ratio.toFixed(2)}:1`,
);
}
}
}
});
test("eleven core foreground/background pairs meet WCAG AA contrast", () => {
assert.deepEqual(CORE_CONTRAST_PAIRS, [
["light text", "#202936", "#f8fafc"],
@ -168,7 +269,12 @@ test("contrast calculation matches WCAG reference endpoints", () => {
assert.equal(contrastRatio("#647184", "#647184"), 1);
});
test("contrast CLI prints all eleven pairs", () => {
test("contrast CLI prints the core and semantic callout pairs", () => {
assert.equal(CALLOUT_CONTRAST_PAIRS.length, 24);
const allPairs = [
...CORE_CONTRAST_PAIRS,
...CALLOUT_CONTRAST_PAIRS,
];
const result = spawnSync(
process.execPath,
[fileURLToPath(new URL("../scripts/contrast.mjs", import.meta.url))],
@ -177,8 +283,9 @@ test("contrast CLI prints all eleven pairs", () => {
const lines = result.stdout.trim().split("\n");
assert.equal(result.status, 0, result.stderr);
assert.equal(lines.length, 11);
for (const [index, [name]] of CORE_CONTRAST_PAIRS.entries()) {
assert.equal(lines.length, 35);
for (const [index, [name, foreground, background]] of allPairs.entries()) {
assert.ok(contrastRatio(foreground, background) >= 4.5, name);
assert.match(lines[index], new RegExp(`^PASS ${name}:`));
}
});

View file

@ -14,6 +14,7 @@ const allowedThemeSelectors = new Set([
":where(.markdown-rendered pre:not(.frontmatter))",
":where(.markdown-rendered pre:not(.frontmatter)) code[class*=language-]",
":where(.markdown-source-view.mod-cm6 .HyperMD-codeblock)",
".markdown-source-view.mod-cm6 :where(.HyperMD-codeblock)",
".markdown-rendered blockquote",
".markdown-rendered blockquote:not(blockquote blockquote)::before",
".markdown-rendered blockquote:not(blockquote blockquote)::after",

View file

@ -27,6 +27,8 @@
--aera-inline-code-background: #d9dee5;
--aera-inline-code-color: #566273;
--aera-callout-background-opacity: 0.08;
--aera-callout-title-semantic-weight: 52%;
--aera-callout-body-semantic-weight: 50%;
--aera-quote-background: #f1f3f6;
--aera-quote-fold: #d9dee5;
}
@ -59,6 +61,8 @@
--aera-inline-code-background: #2a2f36;
--aera-inline-code-color: #b8c0cc;
--aera-callout-background-opacity: 0.12;
--aera-callout-title-semantic-weight: 84%;
--aera-callout-body-semantic-weight: 72%;
--aera-quote-background: #22262c;
--aera-quote-fold: #3a414a;
}
@ -289,6 +293,14 @@ body {
color: #f8f8f2;
}
.markdown-source-view.mod-cm6 :where(.HyperMD-codeblock) {
white-space: pre;
word-break: normal;
overflow-wrap: normal;
width: max-content;
min-width: 100%;
}
body {
--callout-border-width: 0px;
--callout-border-opacity: 0;
@ -303,13 +315,13 @@ body {
position: relative;
overflow: hidden;
background-color: rgba(var(--callout-color), var(--aera-callout-background-opacity));
color: color-mix(in srgb, rgb(var(--callout-color)) 72%, var(--text-normal));
color: color-mix(in srgb, rgb(var(--callout-color)) var(--aera-callout-body-semantic-weight), var(--text-normal));
border: 0;
}
.callout-title {
padding-inline-end: var(--size-4-12);
color: color-mix(in srgb, rgb(var(--callout-color)) 84%, var(--text-normal));
color: color-mix(in srgb, rgb(var(--callout-color)) var(--aera-callout-title-semantic-weight), var(--text-normal));
}
.callout-content {