mirror of
https://github.com/def-peter/obsidian-aera-theme.git
synced 2026-07-22 05:00:27 +00:00
fix: harden callout contrast and code wrapping
This commit is contained in:
parent
afae9e9a5f
commit
13b81bf849
10 changed files with 236 additions and 10 deletions
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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%"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}:`));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
16
theme.css
16
theme.css
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue