diff --git a/screenshot.png b/screenshot.png index af45470..82e5bba 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/scripts/contrast.mjs b/scripts/contrast.mjs index ece5621..e7ea6a3 100644 --- a/scripts/contrast.mjs +++ b/scripts/contrast.mjs @@ -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; diff --git a/src/components/_callouts.scss b/src/components/_callouts.scss index a4808fc..ac824ce 100644 --- a/src/components/_callouts.scss +++ b/src/components/_callouts.scss @@ -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) ); } diff --git a/src/editor/_code.scss b/src/editor/_code.scss index 294bddf..bc9e03e 100644 --- a/src/editor/_code.scss +++ b/src/editor/_code.scss @@ -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%; +} diff --git a/src/foundations/_colors.scss b/src/foundations/_colors.scss index 11e7277..5a4a6d5 100644 --- a/src/foundations/_colors.scss +++ b/src/foundations/_colors.scss @@ -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; } diff --git a/tests/components.test.mjs b/tests/components.test.mjs index 14336c1..f8154f7 100644 --- a/tests/components.test.mjs +++ b/tests/components.test.mjs @@ -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)", diff --git a/tests/editor-blocks.test.mjs b/tests/editor-blocks.test.mjs index aa79d29..61becc1 100644 --- a/tests/editor-blocks.test.mjs +++ b/tests/editor-blocks.test.mjs @@ -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%"], + ], + ); +}); diff --git a/tests/foundations.test.mjs b/tests/foundations.test.mjs index f7cd3e7..2997672 100644 --- a/tests/foundations.test.mjs +++ b/tests/foundations.test.mjs @@ -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}:`)); } }); diff --git a/tests/helpers/css.mjs b/tests/helpers/css.mjs index 3999cec..c533c38 100644 --- a/tests/helpers/css.mjs +++ b/tests/helpers/css.mjs @@ -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", diff --git a/theme.css b/theme.css index b187a06..cfab98e 100644 --- a/theme.css +++ b/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 {