From 030490f91fa89ccaf766420f9abb5ce1ac38894e Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 16:54:45 +0200 Subject: [PATCH 01/27] Add color palette with base 16 + semantic tokens --- src/util/palette.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/util/palette.js diff --git a/src/util/palette.js b/src/util/palette.js new file mode 100644 index 0000000..91773f2 --- /dev/null +++ b/src/util/palette.js @@ -0,0 +1,37 @@ +/** + * Color palette: base 16 ANSI-named colors + semantic tokens. + * Dark-optimized. All values as hex literals (no runtime conversion). + */ + +const base = { + black: "#1e1e2e", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#cba6f7", + cyan: "#94e2d5", + white: "#cdd6f4", + lightblack: "#585b70", + lightred: "#eba0ac", + lightgreen: "#b5e3a7", + lightyellow: "#faedc4", + lightblue: "#a0bff9", + lightmagenta: "#d4b8f7", + lightcyan: "#a9e3d3", + lightwhite: "#e6edf5", +}; + +const semantic = { + pass: base.green, + fail: base.red, + skip: "#7d8590", + message: base.yellow, + highlight: base.green, + text: base.lightwhite, + "diff-added": "#2e4b3a", + "diff-removed": "#4b2e38", + gutter: "#313244", +}; + +export default { ...base, ...semantic }; From 665edfbd112f80840df5067d7ac89c8c85c54a59 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:01:01 +0200 Subject: [PATCH 02/27] Move format-console.js under src/util/ --- src/classes/TestResult.js | 2 +- src/env/console.js | 2 +- src/env/node.js | 2 +- src/render.js | 2 +- src/{ => util}/format-console.js | 0 tests/format-console.js | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename src/{ => util}/format-console.js (100%) diff --git a/src/classes/TestResult.js b/src/classes/TestResult.js index 3b29c9f..d045142 100644 --- a/src/classes/TestResult.js +++ b/src/classes/TestResult.js @@ -1,6 +1,6 @@ import Test from "./Test.js"; import BubblingEventTarget from "./BubblingEventTarget.js"; -import { stripFormatting } from "../format-console.js"; +import { stripFormatting } from "../util/format-console.js"; import { delay, formatDuration, interceptConsole, pluralize, stringify } from "../util.js"; import { formatDiff } from "../util/format-diff.js"; diff --git a/src/env/console.js b/src/env/console.js index 987bff2..9abaa39 100644 --- a/src/env/console.js +++ b/src/env/console.js @@ -1,4 +1,4 @@ -import format from "../format-console.js"; +import format from "../util/format-console.js"; function printTree (str, parent) { if (str.children?.length > 0) { diff --git a/src/env/node.js b/src/env/node.js index b96c764..25a3ff4 100644 --- a/src/env/node.js +++ b/src/env/node.js @@ -10,7 +10,7 @@ import { AsciiTree } from "oo-ascii-tree"; import { globSync } from "glob"; // Internal modules -import format from "../format-console.js"; +import format from "../util/format-console.js"; import { getType } from "../util.js"; /** diff --git a/src/render.js b/src/render.js index f44be94..af74599 100644 --- a/src/render.js +++ b/src/render.js @@ -7,7 +7,7 @@ import TestResult from "./classes/TestResult.js"; import RefTest from "https://html.htest.dev/src/classes/RefTest.js"; import { create, output } from "https://html.htest.dev/src/util.js"; import { formatDuration } from "./util.js"; -import format from "./format-console.js"; +import format from "./util/format-console.js"; export default function render (test) { let root = new Test(test); diff --git a/src/format-console.js b/src/util/format-console.js similarity index 100% rename from src/format-console.js rename to src/util/format-console.js diff --git a/tests/format-console.js b/tests/format-console.js index acc0747..9d5dc7f 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -1,4 +1,4 @@ -import format, { stripFormatting } from "../src/format-console.js"; +import format, { stripFormatting } from "../src/util/format-console.js"; import chalk from "chalk"; // We don't want to use map because it will output unmapped values on fail as well, causing a mess in this very special case From 1ce749ac0c82ba41f7134c056c93c879c49abb97 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:02:46 +0200 Subject: [PATCH 03/27] Replace format-console tests with new spec coverage --- tests/format-console.js | 104 ++++++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 40 deletions(-) diff --git a/tests/format-console.js b/tests/format-console.js index 9d5dc7f..595fa0b 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -1,62 +1,86 @@ -import format, { stripFormatting } from "../src/util/format-console.js"; -import chalk from "chalk"; - -// We don't want to use map because it will output unmapped values on fail as well, causing a mess in this very special case -function escape (str) { - return str.replaceAll("\x1b", "\\x1b"); -} +import format, { stripFormatting, detectMode } from "../src/util/format-console.js"; export default { - name: "Console formatting tests", + name: "format-console", + map: str => str.replaceAll("\x1b", "\\x1b"), tests: [ { - name: "Formatting", - run (str) { - return escape(format(str)); + name: "format()", + run (arg) { + return format(arg, { ...(this.data ?? {}) }); }, tests: [ { - name: "Bold", - args: "bold", - expect: "\\x1b[1mbold\\x1b[0m", - }, - { - name: "Text color", - args: "red", - expect: "\\x1b[31mred\\x1b[0m", + name: "Truecolor", + data: { mode: "truecolor" }, + tests: [ + { name: "Bold modifier", arg: "x", expect: "\x1b[1mx\x1b[0m" }, + { name: "Semantic token", arg: "x", expect: "\x1b[38;2;166;227;161mx\x1b[0m" }, + { name: "Hex literal", arg: "x", expect: "\x1b[38;2;255;0;0mx\x1b[0m" }, + { name: "Background", arg: "x", expect: "\x1b[48;2;166;227;161mx\x1b[0m" }, + { name: "Nested preserves outer", arg: "xy", + expect: "\x1b[38;2;166;227;161m\x1b[38;2;243;139;168mx\x1b[0m\x1b[38;2;166;227;161my\x1b[0m" }, + { name: "Diff-style", arg: " + added - removed", + expect: "\x1b[48;2;49;50;68m \x1b[38;2;46;75;58m+ added\x1b[0m\x1b[48;2;49;50;68m \x1b[38;2;75;46;56m- removed\x1b[0m\x1b[48;2;49;50;68m\x1b[0m" }, + { name: "Unknown color ignored", arg: "x", expect: "x\x1b[0m" }, + ], }, { - name: "Background color", - args: "red", - expect: "\\x1b[41mred\\x1b[0m", + name: "256", + data: { mode: "256" }, + tests: [ + { name: "Semantic token (pass → #a6e3a1 → index 151)", arg: "x", expect: "\x1b[38;5;151mx\x1b[0m" }, + { name: "Hex literal red → index 196", arg: "x", expect: "\x1b[38;5;196mx\x1b[0m" }, + ], }, { - name: "Light color", - args: "light red", - // expect: "\\x1b[91mlight red\\x1b[0m" - expect: escape(chalk.redBright("light red")), + name: "Strip", + data: { mode: "strip" }, + tests: [ + { name: "Colors stripped, modifiers kept", arg: "x", + expect: "\x1b[1mx\x1b[0m\x1b[1m\x1b[0m" }, + ], }, { - name: "Light background color", - args: "light red", - // expect: "\\x1b[101mlight red\\x1b[0m" - expect: escape(chalk.bgRedBright("light red")), + name: "CSS", + data: { css: true }, + tests: [ + { name: "Single foreground", arg: "x", + expect: ["%cx%c", "color:#a6e3a1", ""] }, + { name: "Nested foreground", arg: "x", + expect: ["%c%cx%c%c", "color:#a6e3a1", "color:#f38ba8", "color:#a6e3a1", ""] }, + { name: "Background plus bold", arg: "x", + expect: ["%c%cx%c%c", "font-weight:bold", "font-weight:bold;background:#a6e3a1", "font-weight:bold", ""] }, + { name: "Diff-style", arg: " + added - removed", + expect: ["%c %c+ added%c %c- removed%c%c", + "background:#313244", + "color:#2e4b3a;background:#313244", + "background:#313244", + "color:#4b2e38;background:#313244", + "background:#313244", + ""] }, + ], }, ], }, { - name: "Strip formatting", + name: "detectMode()", + run: detectMode, + tests: [ + { name: "NO_COLOR wins over FORCE_COLOR", arg: { NO_COLOR: "1", FORCE_COLOR: "3" }, expect: "strip" }, + { name: "FORCE_COLOR=3", arg: { FORCE_COLOR: "3" }, expect: "truecolor" }, + { name: "FORCE_COLOR=0", arg: { FORCE_COLOR: "0" }, expect: "strip" }, + { name: "COLORTERM", arg: { COLORTERM: "truecolor" }, expect: "truecolor" }, + { name: "TERM pattern", arg: { TERM: "xterm-truecolor" }, expect: "truecolor" }, + { name: "Default fallback", arg: {}, expect: "256" }, + ], + }, + { + name: "stripFormatting()", run: stripFormatting, tests: [ - { - args: "bold", - expect: "bold", - }, - { - name: "Malformed tags", - args: "bold red?", - expect: "bold red?", - }, + { arg: "x", expect: "x" }, + { name: "Malformed tags", arg: "bold x?", expect: "bold x?" }, ], }, ], From 886bb5241792cb24f326fb81d645156e9be28e25 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:03:45 +0200 Subject: [PATCH 04/27] Drop unused chalk devDependency --- package-lock.json | 1 - package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0912f37..6da283c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,6 @@ "@11ty/eleventy": "^3.1.5", "@11ty/eleventy-navigation": "^1.0.5", "@stylistic/eslint-plugin": "latest", - "chalk": "^5.6.2", "eleventy-plugin-toc": "^1.1.5", "eslint": "latest", "globals": "latest", diff --git a/package.json b/package.json index c7dfa92..bc1ec1e 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "@11ty/eleventy": "^3.1.5", "@11ty/eleventy-navigation": "^1.0.5", "@stylistic/eslint-plugin": "latest", - "chalk": "^5.6.2", "eleventy-plugin-toc": "^1.1.5", "eslint": "latest", "globals": "latest", From 9da0c8c910de450de6004ee438cf796131e7f2cd Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:06:51 +0200 Subject: [PATCH 05/27] Rewrite format-console.js with tokenizer + ANSI/CSS backends --- src/util/format-console.js | 329 ++++++++++++++++++++++++++++--------- tests/format-console.js | 31 ++-- 2 files changed, 266 insertions(+), 94 deletions(-) diff --git a/src/util/format-console.js b/src/util/format-console.js index 879df41..bc1aa1c 100644 --- a/src/util/format-console.js +++ b/src/util/format-console.js @@ -1,117 +1,282 @@ /** - * Format console text with HTML-like tags + * Format console text with HTML-like tags. + * + * Two backends: + * - ANSI (default on Node): truecolor / 256 / strip + * - CSS (default on browser): returns [text, ...styles] for console.log spread */ -// https://stackoverflow.com/a/41407246/90826 -let modifiers = { - reset: "\x1b[0m", - b: "\x1b[1m", - dim: "\x1b[2m", - i: "\x1b[3m", + +import { IS_NODEJS } from "../util.js"; +import palette from "./palette.js"; + +const modifiers = { + b: "\x1b[1m", + i: "\x1b[3m", + dim: "\x1b[2m", }; -let hues = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]; +const cssModifiers = { + b: "font-weight:bold", + i: "font-style:italic", + dim: "opacity:0.6", +}; -let colors = Object.fromEntries(hues.map((hue, i) => [hue, `\x1b[3${i}m`])); -let bgColors = Object.fromEntries(hues.map((hue, i) => [hue, `\x1b[4${i}m`])); +// Matches opening/closing tags. Color value allows letters, digits, dash, hash. +const tagRegex = /<\/?(b|i|dim|c|bg)(?:\s+([\w#-]+))?\s*>/gi; -function getColorCode (hue, {light, bg} = {}) { - if (!hue) { - return ""; +/** + * Detects the appropriate ANSI mode from an env-like object. + * Pure function — takes env as argument for testability. + * @param {Record} [env=process.env] + * @returns {"truecolor" | "256" | "strip"} + */ +export function detectMode (env = (typeof process !== "undefined" ? process.env : {})) { + if (env.NO_COLOR) { + return "strip"; + } + if (env.FORCE_COLOR === "0") { + return "strip"; } - if (hue.startsWith("light")) { - hue = hue.replace("light", ""); - light = true; + if (env.FORCE_COLOR === "3") { + return "truecolor"; + } + if (env.FORCE_COLOR === "2" || env.FORCE_COLOR === "1") { + return "256"; } - let i = hues.indexOf(hue); - if (i === -1) { - return ""; + if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") { + return "truecolor"; } - if (light) { - return `\x1b[${ bg ? 10 : 9 }${i}m`; + let term = env.TERM || ""; + if (/-truecolor|-direct|-24bit/.test(term)) { + return "truecolor"; } - return `\x1b[${ light ? "1;" : ""}${ bg ? 4 : 3 }${i}m`; + return "256"; } -let tags = [ - Object.keys(modifiers).map(tag => ``), - ``, ``, - ``, ``, -]; -let tagRegex = RegExp(tags.flat().join("|"), "gi"); +const detectedMode = IS_NODEJS ? detectMode() : "truecolor"; -export default function format (str) { - if (!str) { - return str; +/** + * Resolve a color name to a hex value. + * Accepts semantic tokens, base names, and hex literals. + */ +function resolveColor (name) { + if (palette[name]) { + return palette[name]; + } + if (/^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(name)) { + return name; + } + return null; +} + +function parseHex (hex) { + hex = hex.replace("#", ""); + if (hex.length === 3) { + hex = hex.split("").map(c => c + c).join(""); + } + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + }; +} + +function ansiTruecolor (hex, { bg } = {}) { + let { r, g, b } = parseHex(hex); + return `\x1b[${ bg ? 48 : 38 };2;${r};${g};${b}m`; +} + +// 256-color cube levels +const cubeLevels = [0, 95, 135, 175, 215, 255]; + +function quantize (value) { + let best = 0; + let bestDelta = Infinity; + for (let i = 0; i < cubeLevels.length; i++) { + let delta = Math.abs(value - cubeLevels[i]); + if (delta < bestDelta) { + bestDelta = delta; + best = i; + } + } + return best; +} + +function ansi256 (hex, { bg } = {}) { + let { r, g, b } = parseHex(hex); + let index = 16 + 36 * quantize(r) + 6 * quantize(g) + quantize(b); + return `\x1b[${ bg ? 48 : 38 };5;${index}m`; +} + +/** + * Tokenize a tagged string into a stream of { type, tag, value } records. + */ +function tokenize (str) { + let tokens = []; + let lastIndex = 0; + for (let match of str.matchAll(tagRegex)) { + if (match.index > lastIndex) { + tokens.push({ type: "text", value: str.slice(lastIndex, match.index) }); + } + let isClose = match[0].startsWith(" { - let isClosing = tag[1] === "/"; - let name = tag.match(/<\/?(\w+)/)[1]; - let color = tag.match(/<(?:bg|c)\s+(\w+)>/)?.[1]; - - if (isClosing) { - if (name === "c") { - colorStack.pop(); + let emitColor = mode === "truecolor" ? ansiTruecolor + : mode === "256" ? ansi256 + : () => ""; + + let replay = () => { + output += "\x1b[0m"; + for (let modifier of activeModifiers) { + output += modifiers[modifier]; + } + let foreground = foregroundStack.findLast(hex => hex); + if (foreground) { + output += emitColor(foreground); + } + let background = backgroundStack.findLast(hex => hex); + if (background) { + output += emitColor(background, { bg: true }); + } + }; + + for (let token of tokens) { + if (token.type === "text") { + output += token.value; + continue; + } + if (token.type === "open") { + if (token.tag === "c") { + let hex = resolveColor(token.value); + foregroundStack.push(hex); + if (hex) { + output += emitColor(hex); + } + } + else if (token.tag === "bg") { + let hex = resolveColor(token.value); + backgroundStack.push(hex); + if (hex) { + output += emitColor(hex, { bg: true }); + } + } + else if (modifiers[token.tag]) { + activeModifiers.add(token.tag); + output += modifiers[token.tag]; } - else if (name === "bg") { - bgStack.pop(); + } + else { + if (token.tag === "c") { + foregroundStack.pop(); } - else if (active.has(name)) { - active.delete(name); + else if (token.tag === "bg") { + backgroundStack.pop(); } else { - // Closing tag for formatting that wasn't active - return ""; + activeModifiers.delete(token.tag); } + replay(); + } + } + return output; +} + +function emitCss (tokens) { + let text = ""; + let styles = []; + let activeModifiers = new Set(); + let foregroundStack = []; + let backgroundStack = []; - let activeColor = colorStack.at(-1); - let colorModifier = getColorCode(activeColor); - let activeBg = bgStack.at(-1); - let bgColorModifier = getColorCode(activeBg, {bg: true}); - return modifiers.reset + [...active].map(name => modifiers[name]).join("") + colorModifier + bgColorModifier; + let pushStyle = () => { + let parts = []; + for (let modifier of activeModifiers) { + parts.push(cssModifiers[modifier]); + } + let foreground = foregroundStack.findLast(hex => hex); + if (foreground) { + parts.push(`color:${foreground}`); + } + let background = backgroundStack.findLast(hex => hex); + if (background) { + parts.push(`background:${background}`); + } + text += "%c"; + styles.push(parts.join(";")); + }; + + for (let token of tokens) { + if (token.type === "text") { + text += token.value; + continue; + } + if (token.type === "open") { + if (token.tag === "c") { + let hex = resolveColor(token.value); + foregroundStack.push(hex); + pushStyle(); + } + else if (token.tag === "bg") { + let hex = resolveColor(token.value); + backgroundStack.push(hex); + pushStyle(); + } + else if (cssModifiers[token.tag]) { + activeModifiers.add(token.tag); + pushStyle(); + } } else { - if (name === "c") { - colorStack.push(color); - return getColorCode(color); + if (token.tag === "c") { + foregroundStack.pop(); } - else if (name === "bg") { - bgStack.push(color); - return getColorCode(color, {bg: true}); + else if (token.tag === "bg") { + backgroundStack.pop(); } else { - active.add(name); - return modifiers[name]; + activeModifiers.delete(token.tag); } + pushStyle(); } - }); + } + + return [text, ...styles]; } -export function stripFormatting (str) { - return str.replace(tagRegex, ""); +/** + * Format a tagged string for the target backend. + * @param {string} str + * @param {{ css?: boolean, mode?: "truecolor" | "256" | "strip" }} [options] + * @returns {string | [string, ...string[]]} + */ +export default function format (str, { css = !IS_NODEJS, mode = detectedMode } = {}) { + if (!str) { + return css ? ["", ""] : str; + } + let tokens = tokenize(String(str)); + return css ? emitCss(tokens) : emitAnsi(tokens, mode); } -// /** -// * Platform agnostic console formatting -// * @param {*} str -// * @param {*} format -// */ -// export default function format (str, format) { -// if (typeof format === "string") { -// format = Object.fromEntries(format.split(/\s+/).map(type => [type, true])); -// } - -// for (let type in format) { -// str = formats[type] ? formats[type](str) : str; -// } -// str = str.replaceAll("\x1b", "\\x1b"); -// return str; -// } +export function stripFormatting (str) { + return String(str).replace(tagRegex, ""); +} diff --git a/tests/format-console.js b/tests/format-console.js index 595fa0b..64b9646 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -1,36 +1,43 @@ import format, { stripFormatting, detectMode } from "../src/util/format-console.js"; +// Escape ANSI escape codes so failure output shows them as visible characters. +// Mirrors previous convention in this file — avoids `map`, which would display +// unmapped raw values alongside, creating unreadable diffs. +function escape (str) { + return str.replaceAll("\x1b", "\\x1b"); +} + export default { name: "format-console", - map: str => str.replaceAll("\x1b", "\\x1b"), tests: [ { name: "format()", run (arg) { - return format(arg, { ...(this.data ?? {}) }); + let result = format(arg, { ...(this.data ?? {}) }); + return typeof result === "string" ? escape(result) : result; }, tests: [ { name: "Truecolor", data: { mode: "truecolor" }, tests: [ - { name: "Bold modifier", arg: "x", expect: "\x1b[1mx\x1b[0m" }, - { name: "Semantic token", arg: "x", expect: "\x1b[38;2;166;227;161mx\x1b[0m" }, - { name: "Hex literal", arg: "x", expect: "\x1b[38;2;255;0;0mx\x1b[0m" }, - { name: "Background", arg: "x", expect: "\x1b[48;2;166;227;161mx\x1b[0m" }, + { name: "Bold modifier", arg: "x", expect: "\\x1b[1mx\\x1b[0m" }, + { name: "Semantic token", arg: "x", expect: "\\x1b[38;2;166;227;161mx\\x1b[0m" }, + { name: "Hex literal", arg: "x", expect: "\\x1b[38;2;255;0;0mx\\x1b[0m" }, + { name: "Background", arg: "x", expect: "\\x1b[48;2;166;227;161mx\\x1b[0m" }, { name: "Nested preserves outer", arg: "xy", - expect: "\x1b[38;2;166;227;161m\x1b[38;2;243;139;168mx\x1b[0m\x1b[38;2;166;227;161my\x1b[0m" }, + expect: "\\x1b[38;2;166;227;161m\\x1b[38;2;243;139;168mx\\x1b[0m\\x1b[38;2;166;227;161my\\x1b[0m" }, { name: "Diff-style", arg: " + added - removed", - expect: "\x1b[48;2;49;50;68m \x1b[38;2;46;75;58m+ added\x1b[0m\x1b[48;2;49;50;68m \x1b[38;2;75;46;56m- removed\x1b[0m\x1b[48;2;49;50;68m\x1b[0m" }, - { name: "Unknown color ignored", arg: "x", expect: "x\x1b[0m" }, + expect: "\\x1b[48;2;49;50;68m \\x1b[38;2;46;75;58m+ added\\x1b[0m\\x1b[48;2;49;50;68m \\x1b[38;2;75;46;56m- removed\\x1b[0m\\x1b[48;2;49;50;68m\\x1b[0m" }, + { name: "Unknown color ignored", arg: "x", expect: "x\\x1b[0m" }, ], }, { name: "256", data: { mode: "256" }, tests: [ - { name: "Semantic token (pass → #a6e3a1 → index 151)", arg: "x", expect: "\x1b[38;5;151mx\x1b[0m" }, - { name: "Hex literal red → index 196", arg: "x", expect: "\x1b[38;5;196mx\x1b[0m" }, + { name: "Semantic token (pass → #a6e3a1 → index 151)", arg: "x", expect: "\\x1b[38;5;151mx\\x1b[0m" }, + { name: "Hex literal red → index 196", arg: "x", expect: "\\x1b[38;5;196mx\\x1b[0m" }, ], }, { @@ -38,7 +45,7 @@ export default { data: { mode: "strip" }, tests: [ { name: "Colors stripped, modifiers kept", arg: "x", - expect: "\x1b[1mx\x1b[0m\x1b[1m\x1b[0m" }, + expect: "\\x1b[1mx\\x1b[0m\\x1b[1m\\x1b[0m" }, ], }, { From 38c9cb8dc73cd7a411236c70729bffcd3b9aa5be Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:08:08 +0200 Subject: [PATCH 06/27] Migrate TestResult.js to semantic color tokens From 233d8d2b22c45a818671df16f046cac9f93b65cc Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:08:35 +0200 Subject: [PATCH 07/27] Use highlight token for active group icon --- src/env/node.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/env/node.js b/src/env/node.js index 25a3ff4..94f17ee 100644 --- a/src/env/node.js +++ b/src/env/node.js @@ -60,7 +60,7 @@ function getTree (msg, i) { let icon = collapsed ? icons.collapsed : icons.expanded; if (highlighted) { - icon = `${ collapsed ? icons.collapsedHighlighted : icons.expandedHighlighted }`; + icon = `${ collapsed ? icons.collapsedHighlighted : icons.expandedHighlighted }`; msg = `${ msg }`; } msg = icon + " " + msg; From db11048aaaa82b7693b7833ddce8b98e6c4702ef Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:09:01 +0200 Subject: [PATCH 08/27] Spread format() CSS array into console calls --- src/env/console.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/env/console.js b/src/env/console.js index 9abaa39..232b74f 100644 --- a/src/env/console.js +++ b/src/env/console.js @@ -2,14 +2,14 @@ import format from "../util/format-console.js"; function printTree (str, parent) { if (str.children?.length > 0) { - console["group" + (parent ? "Collapsed" : "")](format(str)); + console["group" + (parent ? "Collapsed" : "")](...format(str, { css: true })); for (let child of str.children) { printTree(child, str); } console.groupEnd(); } else { - console.log(format(str)); + console.log(...format(str, { css: true })); } } From d289084276f6ce752c9d96ddb3e2dd18620838eb Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:09:28 +0200 Subject: [PATCH 09/27] Spread format() CSS array in render.js details onclick --- src/render.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/render.js b/src/render.js index af74599..1e9fd78 100644 --- a/src/render.js +++ b/src/render.js @@ -83,7 +83,7 @@ export default function render (test) { } else if (!target.pass) { cell.classList.add("details"); - cell.onclick = () => console.log(target.details.map(format).join("\n")); + cell.onclick = () => console.log(...format(target.details.join("\n"))); } tr.dataset.time = formatDuration(target.timeTaken); } From a0ad8f76928793cc2fdb2b0d230703869b876746 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:17:40 +0200 Subject: [PATCH 10/27] Tune green palette for readability --- src/util/palette.js | 2 +- tests/format-console.js | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/util/palette.js b/src/util/palette.js index 91773f2..faa2f08 100644 --- a/src/util/palette.js +++ b/src/util/palette.js @@ -6,7 +6,7 @@ const base = { black: "#1e1e2e", red: "#f38ba8", - green: "#a6e3a1", + green: "#7ee787", yellow: "#f9e2af", blue: "#89b4fa", magenta: "#cba6f7", diff --git a/tests/format-console.js b/tests/format-console.js index 64b9646..475eb60 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -22,11 +22,11 @@ export default { data: { mode: "truecolor" }, tests: [ { name: "Bold modifier", arg: "x", expect: "\\x1b[1mx\\x1b[0m" }, - { name: "Semantic token", arg: "x", expect: "\\x1b[38;2;166;227;161mx\\x1b[0m" }, + { name: "Semantic token", arg: "x", expect: "\\x1b[38;2;126;231;135mx\\x1b[0m" }, { name: "Hex literal", arg: "x", expect: "\\x1b[38;2;255;0;0mx\\x1b[0m" }, - { name: "Background", arg: "x", expect: "\\x1b[48;2;166;227;161mx\\x1b[0m" }, + { name: "Background", arg: "x", expect: "\\x1b[48;2;126;231;135mx\\x1b[0m" }, { name: "Nested preserves outer", arg: "xy", - expect: "\\x1b[38;2;166;227;161m\\x1b[38;2;243;139;168mx\\x1b[0m\\x1b[38;2;166;227;161my\\x1b[0m" }, + expect: "\\x1b[38;2;126;231;135m\\x1b[38;2;243;139;168mx\\x1b[0m\\x1b[38;2;126;231;135my\\x1b[0m" }, { name: "Diff-style", arg: " + added - removed", expect: "\\x1b[48;2;49;50;68m \\x1b[38;2;46;75;58m+ added\\x1b[0m\\x1b[48;2;49;50;68m \\x1b[38;2;75;46;56m- removed\\x1b[0m\\x1b[48;2;49;50;68m\\x1b[0m" }, { name: "Unknown color ignored", arg: "x", expect: "x\\x1b[0m" }, @@ -36,7 +36,7 @@ export default { name: "256", data: { mode: "256" }, tests: [ - { name: "Semantic token (pass → #a6e3a1 → index 151)", arg: "x", expect: "\\x1b[38;5;151mx\\x1b[0m" }, + { name: "Semantic token (pass → #7ee787 → index 114)", arg: "x", expect: "\\x1b[38;5;114mx\\x1b[0m" }, { name: "Hex literal red → index 196", arg: "x", expect: "\\x1b[38;5;196mx\\x1b[0m" }, ], }, @@ -53,11 +53,11 @@ export default { data: { css: true }, tests: [ { name: "Single foreground", arg: "x", - expect: ["%cx%c", "color:#a6e3a1", ""] }, + expect: ["%cx%c", "color:#7ee787", ""] }, { name: "Nested foreground", arg: "x", - expect: ["%c%cx%c%c", "color:#a6e3a1", "color:#f38ba8", "color:#a6e3a1", ""] }, + expect: ["%c%cx%c%c", "color:#7ee787", "color:#f38ba8", "color:#7ee787", ""] }, { name: "Background plus bold", arg: "x", - expect: ["%c%cx%c%c", "font-weight:bold", "font-weight:bold;background:#a6e3a1", "font-weight:bold", ""] }, + expect: ["%c%cx%c%c", "font-weight:bold", "font-weight:bold;background:#7ee787", "font-weight:bold", ""] }, { name: "Diff-style", arg: " + added - removed", expect: ["%c %c+ added%c %c- removed%c%c", "background:#313244", From 447d7f1d0e0404a120cc9cb8215486c0c40fff16 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:27:29 +0200 Subject: [PATCH 11/27] Make palette WCAG-compliant (accessible badge labels + skip token) --- src/util/palette.js | 6 +++--- tests/format-console.js | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/util/palette.js b/src/util/palette.js index faa2f08..1590b27 100644 --- a/src/util/palette.js +++ b/src/util/palette.js @@ -6,7 +6,7 @@ const base = { black: "#1e1e2e", red: "#f38ba8", - green: "#7ee787", + green: "#4ade80", yellow: "#f9e2af", blue: "#89b4fa", magenta: "#cba6f7", @@ -25,10 +25,10 @@ const base = { const semantic = { pass: base.green, fail: base.red, - skip: "#7d8590", + skip: "#a0a8b4", message: base.yellow, highlight: base.green, - text: base.lightwhite, + text: base.black, "diff-added": "#2e4b3a", "diff-removed": "#4b2e38", gutter: "#313244", diff --git a/tests/format-console.js b/tests/format-console.js index 475eb60..31be321 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -22,11 +22,11 @@ export default { data: { mode: "truecolor" }, tests: [ { name: "Bold modifier", arg: "x", expect: "\\x1b[1mx\\x1b[0m" }, - { name: "Semantic token", arg: "x", expect: "\\x1b[38;2;126;231;135mx\\x1b[0m" }, + { name: "Semantic token", arg: "x", expect: "\\x1b[38;2;74;222;128mx\\x1b[0m" }, { name: "Hex literal", arg: "x", expect: "\\x1b[38;2;255;0;0mx\\x1b[0m" }, - { name: "Background", arg: "x", expect: "\\x1b[48;2;126;231;135mx\\x1b[0m" }, + { name: "Background", arg: "x", expect: "\\x1b[48;2;74;222;128mx\\x1b[0m" }, { name: "Nested preserves outer", arg: "xy", - expect: "\\x1b[38;2;126;231;135m\\x1b[38;2;243;139;168mx\\x1b[0m\\x1b[38;2;126;231;135my\\x1b[0m" }, + expect: "\\x1b[38;2;74;222;128m\\x1b[38;2;243;139;168mx\\x1b[0m\\x1b[38;2;74;222;128my\\x1b[0m" }, { name: "Diff-style", arg: " + added - removed", expect: "\\x1b[48;2;49;50;68m \\x1b[38;2;46;75;58m+ added\\x1b[0m\\x1b[48;2;49;50;68m \\x1b[38;2;75;46;56m- removed\\x1b[0m\\x1b[48;2;49;50;68m\\x1b[0m" }, { name: "Unknown color ignored", arg: "x", expect: "x\\x1b[0m" }, @@ -36,7 +36,7 @@ export default { name: "256", data: { mode: "256" }, tests: [ - { name: "Semantic token (pass → #7ee787 → index 114)", arg: "x", expect: "\\x1b[38;5;114mx\\x1b[0m" }, + { name: "Semantic token (pass → #4ade80 → index 78)", arg: "x", expect: "\\x1b[38;5;78mx\\x1b[0m" }, { name: "Hex literal red → index 196", arg: "x", expect: "\\x1b[38;5;196mx\\x1b[0m" }, ], }, @@ -53,11 +53,11 @@ export default { data: { css: true }, tests: [ { name: "Single foreground", arg: "x", - expect: ["%cx%c", "color:#7ee787", ""] }, + expect: ["%cx%c", "color:#4ade80", ""] }, { name: "Nested foreground", arg: "x", - expect: ["%c%cx%c%c", "color:#7ee787", "color:#f38ba8", "color:#7ee787", ""] }, + expect: ["%c%cx%c%c", "color:#4ade80", "color:#f38ba8", "color:#4ade80", ""] }, { name: "Background plus bold", arg: "x", - expect: ["%c%cx%c%c", "font-weight:bold", "font-weight:bold;background:#7ee787", "font-weight:bold", ""] }, + expect: ["%c%cx%c%c", "font-weight:bold", "font-weight:bold;background:#4ade80", "font-weight:bold", ""] }, { name: "Diff-style", arg: " + added - removed", expect: ["%c %c+ added%c %c- removed%c%c", "background:#313244", From 1fcd54d9a2da183d88b6751adf9cad155341cd82 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:33:21 +0200 Subject: [PATCH 12/27] Formatting --- tests/format-console.js | 160 +++++++++++++++++++++++++++++++--------- 1 file changed, 124 insertions(+), 36 deletions(-) diff --git a/tests/format-console.js b/tests/format-console.js index 31be321..e710d42 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -1,4 +1,7 @@ -import format, { stripFormatting, detectMode } from "../src/util/format-console.js"; +import format, { + stripFormatting, + detectMode, +} from "../src/util/format-console.js"; // Escape ANSI escape codes so failure output shows them as visible characters. // Mirrors previous convention in this file — avoids `map`, which would display @@ -21,51 +24,116 @@ export default { name: "Truecolor", data: { mode: "truecolor" }, tests: [ - { name: "Bold modifier", arg: "x", expect: "\\x1b[1mx\\x1b[0m" }, - { name: "Semantic token", arg: "x", expect: "\\x1b[38;2;74;222;128mx\\x1b[0m" }, - { name: "Hex literal", arg: "x", expect: "\\x1b[38;2;255;0;0mx\\x1b[0m" }, - { name: "Background", arg: "x", expect: "\\x1b[48;2;74;222;128mx\\x1b[0m" }, - { name: "Nested preserves outer", arg: "xy", - expect: "\\x1b[38;2;74;222;128m\\x1b[38;2;243;139;168mx\\x1b[0m\\x1b[38;2;74;222;128my\\x1b[0m" }, - { name: "Diff-style", arg: " + added - removed", - expect: "\\x1b[48;2;49;50;68m \\x1b[38;2;46;75;58m+ added\\x1b[0m\\x1b[48;2;49;50;68m \\x1b[38;2;75;46;56m- removed\\x1b[0m\\x1b[48;2;49;50;68m\\x1b[0m" }, - { name: "Unknown color ignored", arg: "x", expect: "x\\x1b[0m" }, + { + name: "Bold modifier", + arg: "x", + expect: "\\x1b[1mx\\x1b[0m", + }, + { + name: "Semantic token", + arg: "x", + expect: "\\x1b[38;2;74;222;128mx\\x1b[0m", + }, + { + name: "Hex literal", + arg: "x", + expect: "\\x1b[38;2;255;0;0mx\\x1b[0m", + }, + { + name: "Background", + arg: "x", + expect: "\\x1b[48;2;74;222;128mx\\x1b[0m", + }, + { + name: "Nested preserves outer", + arg: "xy", + expect: + "\\x1b[38;2;74;222;128m\\x1b[38;2;243;139;168mx\\x1b[0m\\x1b[38;2;74;222;128my\\x1b[0m", + }, + { + name: "Diff-style", + arg: " + added - removed", + expect: + "\\x1b[48;2;49;50;68m \\x1b[38;2;46;75;58m+ added\\x1b[0m\\x1b[48;2;49;50;68m \\x1b[38;2;75;46;56m- removed\\x1b[0m\\x1b[48;2;49;50;68m\\x1b[0m", + }, + { + name: "Unknown color ignored", + arg: "x", + expect: "x\\x1b[0m", + }, ], }, { name: "256", data: { mode: "256" }, tests: [ - { name: "Semantic token (pass → #4ade80 → index 78)", arg: "x", expect: "\\x1b[38;5;78mx\\x1b[0m" }, - { name: "Hex literal red → index 196", arg: "x", expect: "\\x1b[38;5;196mx\\x1b[0m" }, + { + name: "Semantic token (pass → #4ade80 → index 78)", + arg: "x", + expect: "\\x1b[38;5;78mx\\x1b[0m", + }, + { + name: "Hex literal red → index 196", + arg: "x", + expect: "\\x1b[38;5;196mx\\x1b[0m", + }, ], }, { name: "Strip", data: { mode: "strip" }, tests: [ - { name: "Colors stripped, modifiers kept", arg: "x", - expect: "\\x1b[1mx\\x1b[0m\\x1b[1m\\x1b[0m" }, + { + name: "Colors stripped, modifiers kept", + arg: "x", + expect: "\\x1b[1mx\\x1b[0m\\x1b[1m\\x1b[0m", + }, ], }, { name: "CSS", data: { css: true }, tests: [ - { name: "Single foreground", arg: "x", - expect: ["%cx%c", "color:#4ade80", ""] }, - { name: "Nested foreground", arg: "x", - expect: ["%c%cx%c%c", "color:#4ade80", "color:#f38ba8", "color:#4ade80", ""] }, - { name: "Background plus bold", arg: "x", - expect: ["%c%cx%c%c", "font-weight:bold", "font-weight:bold;background:#4ade80", "font-weight:bold", ""] }, - { name: "Diff-style", arg: " + added - removed", - expect: ["%c %c+ added%c %c- removed%c%c", - "background:#313244", - "color:#2e4b3a;background:#313244", - "background:#313244", - "color:#4b2e38;background:#313244", - "background:#313244", - ""] }, + { + name: "Single foreground", + arg: "x", + expect: ["%cx%c", "color:#4ade80", ""], + }, + { + name: "Nested foreground", + arg: "x", + expect: [ + "%c%cx%c%c", + "color:#4ade80", + "color:#f38ba8", + "color:#4ade80", + "", + ], + }, + { + name: "Background plus bold", + arg: "x", + expect: [ + "%c%cx%c%c", + "font-weight:bold", + "font-weight:bold;background:#4ade80", + "font-weight:bold", + "", + ], + }, + { + name: "Diff-style", + arg: " + added - removed", + expect: [ + "%c %c+ added%c %c- removed%c%c", + "background:#313244", + "color:#2e4b3a;background:#313244", + "background:#313244", + "color:#4b2e38;background:#313244", + "background:#313244", + "", + ], + }, ], }, ], @@ -74,20 +142,40 @@ export default { name: "detectMode()", run: detectMode, tests: [ - { name: "NO_COLOR wins over FORCE_COLOR", arg: { NO_COLOR: "1", FORCE_COLOR: "3" }, expect: "strip" }, - { name: "FORCE_COLOR=3", arg: { FORCE_COLOR: "3" }, expect: "truecolor" }, - { name: "FORCE_COLOR=0", arg: { FORCE_COLOR: "0" }, expect: "strip" }, - { name: "COLORTERM", arg: { COLORTERM: "truecolor" }, expect: "truecolor" }, - { name: "TERM pattern", arg: { TERM: "xterm-truecolor" }, expect: "truecolor" }, - { name: "Default fallback", arg: {}, expect: "256" }, + { + name: "NO_COLOR wins over FORCE_COLOR", + arg: { NO_COLOR: "1", FORCE_COLOR: "3" }, + expect: "strip", + }, + { + name: "FORCE_COLOR=3", + arg: { FORCE_COLOR: "3" }, + expect: "truecolor", + }, + { name: "FORCE_COLOR=0", arg: { FORCE_COLOR: "0" }, expect: "strip" }, + { + name: "COLORTERM", + arg: { COLORTERM: "truecolor" }, + expect: "truecolor", + }, + { + name: "TERM pattern", + arg: { TERM: "xterm-truecolor" }, + expect: "truecolor", + }, + { name: "Default fallback", arg: {}, expect: "256" }, ], }, { name: "stripFormatting()", run: stripFormatting, tests: [ - { arg: "x", expect: "x" }, - { name: "Malformed tags", arg: "bold x?", expect: "bold x?" }, + { arg: "x", expect: "x" }, + { + name: "Malformed tags", + arg: "bold x?", + expect: "bold x?", + }, ], }, ], From 97dc42de62074327a5f529d5348a737e31f3eddf Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:44:10 +0200 Subject: [PATCH 13/27] Add diff-added-emph and diff-removed-emph tokens for inline highlights --- src/util/palette.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/util/palette.js b/src/util/palette.js index 1590b27..4e978ee 100644 --- a/src/util/palette.js +++ b/src/util/palette.js @@ -29,9 +29,11 @@ const semantic = { message: base.yellow, highlight: base.green, text: base.black, - "diff-added": "#2e4b3a", - "diff-removed": "#4b2e38", - gutter: "#313244", + "diff-added": "#2e4b3a", + "diff-added-emph": base.lightgreen, + "diff-removed": "#4b2e38", + "diff-removed-emph": base.lightred, + gutter: "#313244", }; export default { ...base, ...semantic }; From 9295bec11d1d0ff2dfada7e1f991d4797fa652a3 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 17:56:12 +0200 Subject: [PATCH 14/27] Prettier --- src/util/format-console.js | 29 +++++++++++++--------- src/util/palette.js | 50 +++++++++++++++++++------------------- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/util/format-console.js b/src/util/format-console.js index bc1aa1c..d47303c 100644 --- a/src/util/format-console.js +++ b/src/util/format-console.js @@ -10,14 +10,14 @@ import { IS_NODEJS } from "../util.js"; import palette from "./palette.js"; const modifiers = { - b: "\x1b[1m", - i: "\x1b[3m", + b: "\x1b[1m", + i: "\x1b[3m", dim: "\x1b[2m", }; const cssModifiers = { - b: "font-weight:bold", - i: "font-style:italic", + b: "font-weight:bold", + i: "font-style:italic", dim: "opacity:0.6", }; @@ -30,7 +30,7 @@ const tagRegex = /<\/?(b|i|dim|c|bg)(?:\s+([\w#-]+))?\s*>/gi; * @param {Record} [env=process.env] * @returns {"truecolor" | "256" | "strip"} */ -export function detectMode (env = (typeof process !== "undefined" ? process.env : {})) { +export function detectMode (env = process?.env ?? {}) { if (env.NO_COLOR) { return "strip"; } @@ -75,7 +75,10 @@ function resolveColor (name) { function parseHex (hex) { hex = hex.replace("#", ""); if (hex.length === 3) { - hex = hex.split("").map(c => c + c).join(""); + hex = hex + .split("") + .map(c => c + c) + .join(""); } return { r: parseInt(hex.slice(0, 2), 16), @@ -86,7 +89,7 @@ function parseHex (hex) { function ansiTruecolor (hex, { bg } = {}) { let { r, g, b } = parseHex(hex); - return `\x1b[${ bg ? 48 : 38 };2;${r};${g};${b}m`; + return `\x1b[${bg ? 48 : 38};2;${r};${g};${b}m`; } // 256-color cube levels @@ -108,7 +111,7 @@ function quantize (value) { function ansi256 (hex, { bg } = {}) { let { r, g, b } = parseHex(hex); let index = 16 + 36 * quantize(r) + 6 * quantize(g) + quantize(b); - return `\x1b[${ bg ? 48 : 38 };5;${index}m`; + return `\x1b[${bg ? 48 : 38};5;${index}m`; } /** @@ -141,9 +144,8 @@ function emitAnsi (tokens, mode) { let foregroundStack = []; let backgroundStack = []; - let emitColor = mode === "truecolor" ? ansiTruecolor - : mode === "256" ? ansi256 - : () => ""; + let emitColor = + mode === "truecolor" ? ansiTruecolor : mode === "256" ? ansi256 : () => ""; let replay = () => { output += "\x1b[0m"; @@ -269,7 +271,10 @@ function emitCss (tokens) { * @param {{ css?: boolean, mode?: "truecolor" | "256" | "strip" }} [options] * @returns {string | [string, ...string[]]} */ -export default function format (str, { css = !IS_NODEJS, mode = detectedMode } = {}) { +export default function format ( + str, + { css = !IS_NODEJS, mode = detectedMode } = {}, +) { if (!str) { return css ? ["", ""] : str; } diff --git a/src/util/palette.js b/src/util/palette.js index 4e978ee..c1e7935 100644 --- a/src/util/palette.js +++ b/src/util/palette.js @@ -4,36 +4,36 @@ */ const base = { - black: "#1e1e2e", - red: "#f38ba8", - green: "#4ade80", - yellow: "#f9e2af", - blue: "#89b4fa", - magenta: "#cba6f7", - cyan: "#94e2d5", - white: "#cdd6f4", - lightblack: "#585b70", - lightred: "#eba0ac", - lightgreen: "#b5e3a7", - lightyellow: "#faedc4", - lightblue: "#a0bff9", + black: "#1e1e2e", + red: "#f38ba8", + green: "#4ade80", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#cba6f7", + cyan: "#94e2d5", + white: "#cdd6f4", + lightblack: "#585b70", + lightred: "#eba0ac", + lightgreen: "#b5e3a7", + lightyellow: "#faedc4", + lightblue: "#a0bff9", lightmagenta: "#d4b8f7", - lightcyan: "#a9e3d3", - lightwhite: "#e6edf5", + lightcyan: "#a9e3d3", + lightwhite: "#e6edf5", }; const semantic = { - pass: base.green, - fail: base.red, - skip: "#a0a8b4", - message: base.yellow, - highlight: base.green, - text: base.black, - "diff-added": "#2e4b3a", - "diff-added-emph": base.lightgreen, - "diff-removed": "#4b2e38", + pass: base.green, + fail: base.red, + skip: "#a0a8b4", + message: base.yellow, + highlight: base.green, + text: base.black, + "diff-added": "#2e4b3a", + "diff-added-emph": base.lightgreen, + "diff-removed": "#4b2e38", "diff-removed-emph": base.lightred, - gutter: "#313244", + gutter: "#313244", }; export default { ...base, ...semantic }; From c09782a5fe9188437059667bb0de1d1192dadf62 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 18:01:40 +0200 Subject: [PATCH 15/27] Palette works fine in the light mode also --- src/util/palette.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/palette.js b/src/util/palette.js index c1e7935..ce64dbe 100644 --- a/src/util/palette.js +++ b/src/util/palette.js @@ -1,6 +1,6 @@ /** * Color palette: base 16 ANSI-named colors + semantic tokens. - * Dark-optimized. All values as hex literals (no runtime conversion). + * All values as hex literals (no runtime conversion). */ const base = { From 504fc961ee421b4204355ef09bd655105711df02 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 18:08:32 +0200 Subject: [PATCH 16/27] Add tests for null-skip, FORCE_COLOR=2, 3-char hex, empty input --- tests/format-console.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/format-console.js b/tests/format-console.js index e710d42..595d74f 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -61,6 +61,22 @@ export default { arg: "x", expect: "x\\x1b[0m", }, + { + name: "Nested unknown preserves outer", + arg: "x", + expect: + "\\x1b[38;2;74;222;128m\\x1b[1mx\\x1b[0m\\x1b[38;2;74;222;128m\\x1b[0m\\x1b[38;2;74;222;128m\\x1b[0m", + }, + { + name: "3-char hex expansion", + arg: "x", + expect: "\\x1b[38;2;255;0;0mx\\x1b[0m", + }, + { + name: "Empty string", + arg: "", + expect: "", + }, ], }, { @@ -152,6 +168,7 @@ export default { arg: { FORCE_COLOR: "3" }, expect: "truecolor", }, + { name: "FORCE_COLOR=2", arg: { FORCE_COLOR: "2" }, expect: "256" }, { name: "FORCE_COLOR=0", arg: { FORCE_COLOR: "0" }, expect: "strip" }, { name: "COLORTERM", From d8bf5326fe5510b976022db767f74e915281248c Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 18:16:49 +0200 Subject: [PATCH 17/27] Simplify code --- src/util/format-console.js | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/util/format-console.js b/src/util/format-console.js index d47303c..bab63ce 100644 --- a/src/util/format-console.js +++ b/src/util/format-console.js @@ -31,29 +31,21 @@ const tagRegex = /<\/?(b|i|dim|c|bg)(?:\s+([\w#-]+))?\s*>/gi; * @returns {"truecolor" | "256" | "strip"} */ export function detectMode (env = process?.env ?? {}) { - if (env.NO_COLOR) { - return "strip"; - } - if (env.FORCE_COLOR === "0") { - return "strip"; - } - if (env.FORCE_COLOR === "3") { - return "truecolor"; - } - if (env.FORCE_COLOR === "2" || env.FORCE_COLOR === "1") { - return "256"; - } + let ret = "256"; // env.FORCE_COLOR === "2" || env.FORCE_COLOR === "1" || no env; - if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") { - return "truecolor"; + if (env.NO_COLOR || env.FORCE_COLOR === "0") { + ret = "strip"; } - - let term = env.TERM || ""; - if (/-truecolor|-direct|-24bit/.test(term)) { - return "truecolor"; + else if ( + env.FORCE_COLOR === "3" || + env.COLORTERM === "truecolor" || + env.COLORTERM === "24bit" || + /-truecolor|-direct|-24bit/.test(env.TERM ?? "") + ) { + ret = "truecolor"; } - return "256"; + return ret; } const detectedMode = IS_NODEJS ? detectMode() : "truecolor"; From 23277ba6123c94017ee68f08b76cd8dfe9be7c23 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 18:31:41 +0200 Subject: [PATCH 18/27] Iterate --- src/util/format-console.js | 26 ++++++++++---------------- tests/format-console.js | 24 ++++++++++++------------ 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/util/format-console.js b/src/util/format-console.js index bab63ce..9bfbdbb 100644 --- a/src/util/format-console.js +++ b/src/util/format-console.js @@ -10,15 +10,9 @@ import { IS_NODEJS } from "../util.js"; import palette from "./palette.js"; const modifiers = { - b: "\x1b[1m", - i: "\x1b[3m", - dim: "\x1b[2m", -}; - -const cssModifiers = { - b: "font-weight:bold", - i: "font-style:italic", - dim: "opacity:0.6", + b: { ansi: "\x1b[1m", css: "font-weight: bold" }, + i: { ansi: "\x1b[3m", css: "font-style: italic" }, + dim: { ansi: "\x1b[2m", css: "opacity: 0.6" }, }; // Matches opening/closing tags. Color value allows letters, digits, dash, hash. @@ -142,7 +136,7 @@ function emitAnsi (tokens, mode) { let replay = () => { output += "\x1b[0m"; for (let modifier of activeModifiers) { - output += modifiers[modifier]; + output += modifiers[modifier].ansi; } let foreground = foregroundStack.findLast(hex => hex); if (foreground) { @@ -176,7 +170,7 @@ function emitAnsi (tokens, mode) { } else if (modifiers[token.tag]) { activeModifiers.add(token.tag); - output += modifiers[token.tag]; + output += modifiers[token.tag].ansi; } } else { @@ -205,18 +199,18 @@ function emitCss (tokens) { let pushStyle = () => { let parts = []; for (let modifier of activeModifiers) { - parts.push(cssModifiers[modifier]); + parts.push(modifiers[modifier].css); } let foreground = foregroundStack.findLast(hex => hex); if (foreground) { - parts.push(`color:${foreground}`); + parts.push(`color: ${foreground}`); } let background = backgroundStack.findLast(hex => hex); if (background) { - parts.push(`background:${background}`); + parts.push(`background: ${background}`); } text += "%c"; - styles.push(parts.join(";")); + styles.push(parts.join("; ")); }; for (let token of tokens) { @@ -235,7 +229,7 @@ function emitCss (tokens) { backgroundStack.push(hex); pushStyle(); } - else if (cssModifiers[token.tag]) { + else if (modifiers[token.tag]) { activeModifiers.add(token.tag); pushStyle(); } diff --git a/tests/format-console.js b/tests/format-console.js index 595d74f..a0865b8 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -113,16 +113,16 @@ export default { { name: "Single foreground", arg: "x", - expect: ["%cx%c", "color:#4ade80", ""], + expect: ["%cx%c", "color: #4ade80", ""], }, { name: "Nested foreground", arg: "x", expect: [ "%c%cx%c%c", - "color:#4ade80", - "color:#f38ba8", - "color:#4ade80", + "color: #4ade80", + "color: #f38ba8", + "color: #4ade80", "", ], }, @@ -131,9 +131,9 @@ export default { arg: "x", expect: [ "%c%cx%c%c", - "font-weight:bold", - "font-weight:bold;background:#4ade80", - "font-weight:bold", + "font-weight: bold", + "font-weight: bold; background: #4ade80", + "font-weight: bold", "", ], }, @@ -142,11 +142,11 @@ export default { arg: " + added - removed", expect: [ "%c %c+ added%c %c- removed%c%c", - "background:#313244", - "color:#2e4b3a;background:#313244", - "background:#313244", - "color:#4b2e38;background:#313244", - "background:#313244", + "background: #313244", + "color: #2e4b3a; background: #313244", + "background: #313244", + "color: #4b2e38; background: #313244", + "background: #313244", "", ], }, From 665dd3d2c97f141287adf65b2ebe68111fb683b1 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 18:37:40 +0200 Subject: [PATCH 19/27] Unexport detectMode, cover all modes with shared args --- src/util/format-console.js | 2 +- tests/format-console.js | 44 ++++++++++---------------------------- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/src/util/format-console.js b/src/util/format-console.js index 9bfbdbb..dab9ef2 100644 --- a/src/util/format-console.js +++ b/src/util/format-console.js @@ -24,7 +24,7 @@ const tagRegex = /<\/?(b|i|dim|c|bg)(?:\s+([\w#-]+))?\s*>/gi; * @param {Record} [env=process.env] * @returns {"truecolor" | "256" | "strip"} */ -export function detectMode (env = process?.env ?? {}) { +function detectMode (env = process?.env ?? {}) { let ret = "256"; // env.FORCE_COLOR === "2" || env.FORCE_COLOR === "1" || no env; if (env.NO_COLOR || env.FORCE_COLOR === "0") { diff --git a/tests/format-console.js b/tests/format-console.js index a0865b8..d383a1b 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -1,7 +1,4 @@ -import format, { - stripFormatting, - detectMode, -} from "../src/util/format-console.js"; +import format, { stripFormatting } from "../src/util/format-console.js"; // Escape ANSI escape codes so failure output shows them as visible characters. // Mirrors previous convention in this file — avoids `map`, which would display @@ -99,6 +96,16 @@ export default { name: "Strip", data: { mode: "strip" }, tests: [ + { + name: "Semantic token stripped", + arg: "x", + expect: "x\\x1b[0m", + }, + { + name: "Hex literal stripped", + arg: "x", + expect: "x\\x1b[0m", + }, { name: "Colors stripped, modifiers kept", arg: "x", @@ -154,35 +161,6 @@ export default { }, ], }, - { - name: "detectMode()", - run: detectMode, - tests: [ - { - name: "NO_COLOR wins over FORCE_COLOR", - arg: { NO_COLOR: "1", FORCE_COLOR: "3" }, - expect: "strip", - }, - { - name: "FORCE_COLOR=3", - arg: { FORCE_COLOR: "3" }, - expect: "truecolor", - }, - { name: "FORCE_COLOR=2", arg: { FORCE_COLOR: "2" }, expect: "256" }, - { name: "FORCE_COLOR=0", arg: { FORCE_COLOR: "0" }, expect: "strip" }, - { - name: "COLORTERM", - arg: { COLORTERM: "truecolor" }, - expect: "truecolor", - }, - { - name: "TERM pattern", - arg: { TERM: "xterm-truecolor" }, - expect: "truecolor", - }, - { name: "Default fallback", arg: {}, expect: "256" }, - ], - }, { name: "stripFormatting()", run: stripFormatting, From a1df89c8d47516bce15d2619a6d21bb0b9c39677 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 19:09:59 +0200 Subject: [PATCH 20/27] Simplify format-console emitters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flatten c/bg symmetry via isColor/isBg + single stack selector. - Rename foregroundStack/backgroundStack → colorStack/bgStack. - Use findLast(Boolean) idiom over findLast(hex => hex). - Merge defaultMode into detectMode; env default keys off IS_NODEJS so browser short-circuits to "truecolor" without a wrapper. - Cache detectedMode at module load. - Drop asymmetric empty-string early return; let tokenize handle str ?? "". - Drop WHAT comments; add WHY notes on cubeLevels, null push, replay, detectMode caching. --- src/util/format-console.js | 122 +++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 66 deletions(-) diff --git a/src/util/format-console.js b/src/util/format-console.js index dab9ef2..60582c4 100644 --- a/src/util/format-console.js +++ b/src/util/format-console.js @@ -15,16 +15,19 @@ const modifiers = { dim: { ansi: "\x1b[2m", css: "opacity: 0.6" }, }; -// Matches opening/closing tags. Color value allows letters, digits, dash, hash. const tagRegex = /<\/?(b|i|dim|c|bg)(?:\s+([\w#-]+))?\s*>/gi; /** - * Detects the appropriate ANSI mode from an env-like object. - * Pure function — takes env as argument for testability. - * @param {Record} [env=process.env] + * Detect ANSI mode from an env-like object. Browser falls back to truecolor (CSS backend). + * Pure — takes env as argument for testability; default path is cached in `detectedMode`. + * @param {Record | null} [env] * @returns {"truecolor" | "256" | "strip"} */ -function detectMode (env = process?.env ?? {}) { +function detectMode (env = IS_NODEJS ? process.env : null) { + if (!env) { + return "truecolor"; + } + let ret = "256"; // env.FORCE_COLOR === "2" || env.FORCE_COLOR === "1" || no env; if (env.NO_COLOR || env.FORCE_COLOR === "0") { @@ -42,7 +45,7 @@ function detectMode (env = process?.env ?? {}) { return ret; } -const detectedMode = IS_NODEJS ? detectMode() : "truecolor"; +const detectedMode = detectMode(); /** * Resolve a color name to a hex value. @@ -78,7 +81,7 @@ function ansiTruecolor (hex, { bg } = {}) { return `\x1b[${bg ? 48 : 38};2;${r};${g};${b}m`; } -// 256-color cube levels +// Reference points of the xterm 6×6×6 color cube (indices 16–231). const cubeLevels = [0, 95, 135, 175, 215, 255]; function quantize (value) { @@ -127,8 +130,8 @@ function tokenize (str) { function emitAnsi (tokens, mode) { let output = ""; let activeModifiers = new Set(); - let foregroundStack = []; - let backgroundStack = []; + let colorStack = []; + let bgStack = []; let emitColor = mode === "truecolor" ? ansiTruecolor : mode === "256" ? ansi256 : () => ""; @@ -138,13 +141,13 @@ function emitAnsi (tokens, mode) { for (let modifier of activeModifiers) { output += modifiers[modifier].ansi; } - let foreground = foregroundStack.findLast(hex => hex); - if (foreground) { - output += emitColor(foreground); + let color = colorStack.findLast(Boolean); + if (color) { + output += emitColor(color); } - let background = backgroundStack.findLast(hex => hex); - if (background) { - output += emitColor(background, { bg: true }); + let bg = bgStack.findLast(Boolean); + if (bg) { + output += emitColor(bg, { bg: true }); } }; @@ -153,36 +156,33 @@ function emitAnsi (tokens, mode) { output += token.value; continue; } + + let isBg = token.tag === "bg"; + let isColor = token.tag === "c" || isBg; + let stack = isBg ? bgStack : colorStack; + if (token.type === "open") { - if (token.tag === "c") { + if (isColor) { let hex = resolveColor(token.value); - foregroundStack.push(hex); + // Push even when null so close's pop stays balanced. + stack.push(hex); if (hex) { - output += emitColor(hex); + output += emitColor(hex, { bg: isBg }); } } - else if (token.tag === "bg") { - let hex = resolveColor(token.value); - backgroundStack.push(hex); - if (hex) { - output += emitColor(hex, { bg: true }); - } - } - else if (modifiers[token.tag]) { + else { activeModifiers.add(token.tag); output += modifiers[token.tag].ansi; } } else { - if (token.tag === "c") { - foregroundStack.pop(); - } - else if (token.tag === "bg") { - backgroundStack.pop(); + if (isColor) { + stack.pop(); } else { activeModifiers.delete(token.tag); } + // ANSI has no "close this color" code, so reset and replay remaining state. replay(); } } @@ -193,21 +193,21 @@ function emitCss (tokens) { let text = ""; let styles = []; let activeModifiers = new Set(); - let foregroundStack = []; - let backgroundStack = []; + let colorStack = []; + let bgStack = []; let pushStyle = () => { let parts = []; for (let modifier of activeModifiers) { parts.push(modifiers[modifier].css); } - let foreground = foregroundStack.findLast(hex => hex); - if (foreground) { - parts.push(`color: ${foreground}`); + let color = colorStack.findLast(Boolean); + if (color) { + parts.push(`color: ${color}`); } - let background = backgroundStack.findLast(hex => hex); - if (background) { - parts.push(`background: ${background}`); + let bg = bgStack.findLast(Boolean); + if (bg) { + parts.push(`background: ${bg}`); } text += "%c"; styles.push(parts.join("; ")); @@ -218,34 +218,27 @@ function emitCss (tokens) { text += token.value; continue; } - if (token.type === "open") { - if (token.tag === "c") { - let hex = resolveColor(token.value); - foregroundStack.push(hex); - pushStyle(); - } - else if (token.tag === "bg") { - let hex = resolveColor(token.value); - backgroundStack.push(hex); - pushStyle(); + + let isBg = token.tag === "bg"; + let isColor = token.tag === "c" || isBg; + let isOpen = token.type === "open"; + + if (isColor) { + let stack = isBg ? bgStack : colorStack; + if (isOpen) { + stack.push(resolveColor(token.value)); } - else if (modifiers[token.tag]) { - activeModifiers.add(token.tag); - pushStyle(); + else { + stack.pop(); } } + else if (isOpen) { + activeModifiers.add(token.tag); + } else { - if (token.tag === "c") { - foregroundStack.pop(); - } - else if (token.tag === "bg") { - backgroundStack.pop(); - } - else { - activeModifiers.delete(token.tag); - } - pushStyle(); + activeModifiers.delete(token.tag); } + pushStyle(); } return [text, ...styles]; @@ -261,10 +254,7 @@ export default function format ( str, { css = !IS_NODEJS, mode = detectedMode } = {}, ) { - if (!str) { - return css ? ["", ""] : str; - } - let tokens = tokenize(String(str)); + let tokens = tokenize(String(str ?? "")); return css ? emitCss(tokens) : emitAnsi(tokens, mode); } From 40476ec11bd237dd697baa305e88f68b54c05b66 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 19:13:31 +0200 Subject: [PATCH 21/27] Add reset modifier for ANSI reset escape --- src/util/format-console.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/util/format-console.js b/src/util/format-console.js index 60582c4..145b091 100644 --- a/src/util/format-console.js +++ b/src/util/format-console.js @@ -9,7 +9,9 @@ import { IS_NODEJS } from "../util.js"; import palette from "./palette.js"; +// `reset` is internal-only — not matched by tagRegex, used by emitAnsi's replay. const modifiers = { + reset: { ansi: "\x1b[0m" }, b: { ansi: "\x1b[1m", css: "font-weight: bold" }, i: { ansi: "\x1b[3m", css: "font-style: italic" }, dim: { ansi: "\x1b[2m", css: "opacity: 0.6" }, @@ -137,7 +139,7 @@ function emitAnsi (tokens, mode) { mode === "truecolor" ? ansiTruecolor : mode === "256" ? ansi256 : () => ""; let replay = () => { - output += "\x1b[0m"; + output += modifiers.reset.ansi; for (let modifier of activeModifiers) { output += modifiers[modifier].ansi; } From a7edbc58ba0cf74f13dd6159c9b479adae78ae99 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 20:08:14 +0200 Subject: [PATCH 22/27] Collapse format() options into positional mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `format(str, { css, mode })` with `format(str, mode)`. Modes: "truecolor" | "256" | "strip" | "css". Browser detection falls back to "css" instead of "truecolor". Updates env/console.js and tests to match. render.js and env/node.js use defaults — unchanged. --- src/env/console.js | 4 ++-- src/util/format-console.js | 23 ++++++++++------------- tests/format-console.js | 4 ++-- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/env/console.js b/src/env/console.js index 232b74f..38ac6ca 100644 --- a/src/env/console.js +++ b/src/env/console.js @@ -2,14 +2,14 @@ import format from "../util/format-console.js"; function printTree (str, parent) { if (str.children?.length > 0) { - console["group" + (parent ? "Collapsed" : "")](...format(str, { css: true })); + console["group" + (parent ? "Collapsed" : "")](...format(str, "css")); for (let child of str.children) { printTree(child, str); } console.groupEnd(); } else { - console.log(...format(str, { css: true })); + console.log(...format(str, "css")); } } diff --git a/src/util/format-console.js b/src/util/format-console.js index 145b091..4e81ee2 100644 --- a/src/util/format-console.js +++ b/src/util/format-console.js @@ -1,9 +1,9 @@ /** * Format console text with HTML-like tags. * - * Two backends: - * - ANSI (default on Node): truecolor / 256 / strip - * - CSS (default on browser): returns [text, ...styles] for console.log spread + * Modes: + * - "truecolor" / "256" / "strip" — ANSI backend (default on Node). + * - "css" — returns [text, ...styles] for console.log spread (default on browser). */ import { IS_NODEJS } from "../util.js"; @@ -20,14 +20,14 @@ const modifiers = { const tagRegex = /<\/?(b|i|dim|c|bg)(?:\s+([\w#-]+))?\s*>/gi; /** - * Detect ANSI mode from an env-like object. Browser falls back to truecolor (CSS backend). + * Detect format mode from an env-like object. Browser falls back to "css" (CSS backend). * Pure — takes env as argument for testability; default path is cached in `detectedMode`. * @param {Record | null} [env] - * @returns {"truecolor" | "256" | "strip"} + * @returns {"truecolor" | "256" | "strip" | "css"} */ function detectMode (env = IS_NODEJS ? process.env : null) { if (!env) { - return "truecolor"; + return "css"; } let ret = "256"; // env.FORCE_COLOR === "2" || env.FORCE_COLOR === "1" || no env; @@ -247,17 +247,14 @@ function emitCss (tokens) { } /** - * Format a tagged string for the target backend. + * Format a tagged string for the target mode. * @param {string} str - * @param {{ css?: boolean, mode?: "truecolor" | "256" | "strip" }} [options] + * @param {"truecolor" | "256" | "strip" | "css"} [mode] * @returns {string | [string, ...string[]]} */ -export default function format ( - str, - { css = !IS_NODEJS, mode = detectedMode } = {}, -) { +export default function format (str, mode = detectedMode) { let tokens = tokenize(String(str ?? "")); - return css ? emitCss(tokens) : emitAnsi(tokens, mode); + return mode === "css" ? emitCss(tokens) : emitAnsi(tokens, mode); } export function stripFormatting (str) { diff --git a/tests/format-console.js b/tests/format-console.js index d383a1b..5afc0ab 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -13,7 +13,7 @@ export default { { name: "format()", run (arg) { - let result = format(arg, { ...(this.data ?? {}) }); + let result = format(arg, this.data?.mode); return typeof result === "string" ? escape(result) : result; }, tests: [ @@ -115,7 +115,7 @@ export default { }, { name: "CSS", - data: { css: true }, + data: { mode: "css" }, tests: [ { name: "Single foreground", From 3daa746f30c66b1af2c01a2b978257f37ed2b3e9 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 21:08:32 +0200 Subject: [PATCH 23/27] Migrate format-diff to semantic color tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chunk bg: red/green → diff-removed-emph/diff-added-emph. - Line bg: single neutral lightblack → side-specific diff-removed/diff-added. - Import path: ../format-console.js → ./format-console.js (sibling in src/util/). - sides config: color → { chunk, line }; colorize reads both. --- src/util/format-diff.js | 22 +++++++++---------- tests/format-diff.js | 48 ++++++++++++++++++++--------------------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/util/format-diff.js b/src/util/format-diff.js index aebb98e..f18cb99 100644 --- a/src/util/format-diff.js +++ b/src/util/format-diff.js @@ -1,5 +1,5 @@ import { IS_NODEJS, getType, pluralize, stringify } from "../util.js"; -import { stripFormatting } from "../format-console.js"; +import { stripFormatting } from "./format-console.js"; // Dual Node/browser import. Kept version in the CDN URL in sync with package.json. // `diffWordsWithSpace` keeps whitespace as part of token boundaries so the @@ -18,10 +18,10 @@ const CONTEXT = 2; /** Longer single-line text switches to two-line word-diff layout. */ const INLINE_MAX = 40; -/** Per-side color, change-action key, and output label for diff formatting. */ +/** Per-side chunk/line bg tokens, change-action key, and output label for diff formatting. */ const sides = { - actual: { color: "red", action: "removed", label: " Actual: " }, - expected: { color: "green", action: "added", label: " Expected: " }, + actual: { chunk: "diff-removed-emph", line: "diff-removed", action: "removed", label: " Actual: " }, + expected: { chunk: "diff-added-emph", line: "diff-added", action: "added", label: " Expected: " }, }; /** @@ -246,16 +246,16 @@ function lineDiff (actualString, expectedString, unmapped) { } /** - * Format one side of a change array. Every changed run gets `` + * Format one side of a change array. Every changed run gets `` * so all diffs — whitespace, token, or char — carry the same visual primitive. * * Without `prefix`: returns mixed common/changed text; caller decides line framing. - * With `prefix`: wraps the whole line in neutral `` with `prefix` - * in front; chunk bgs inside stack over the neutral line bg so changed chars - * pop while the rest of the line keeps a faint "this line changed" tint. + * With `prefix`: wraps the whole line in `` with `prefix` in front; + * chunk bgs inside stack over the line bg so changed chars pop while the rest + * of the line keeps a faint "this line changed" tint. */ function colorize (changes, side, prefix) { - let { color, action } = sides[side]; + let { chunk, line, action } = sides[side]; let ret = ""; for (let change of changes) { @@ -264,14 +264,14 @@ function colorize (changes, side, prefix) { } if (change[action]) { - ret += `${ change.value }`; + ret += `${ change.value }`; } else { ret += change.value; } } - return prefix ? `${ prefix } ${ ret }` : ret; + return prefix ? `${ prefix } ${ ret }` : ret; } /** diff --git a/tests/format-diff.js b/tests/format-diff.js index 6cdd060..cd3bf87 100644 --- a/tests/format-diff.js +++ b/tests/format-diff.js @@ -32,31 +32,31 @@ export default { { name: "Inline char diff", args: ["abc", "adc"], - expect: `Got "abc", expected "adc"`, + expect: `Got "abc", expected "adc"`, }, { name: "Inline char diff with unmapped values", args: ["abc", "adc", { actual: "foo", expected: "bar" }], - expect: `Got "abc" ("foo" unmapped), expected "adc" ("bar" unmapped)`, + expect: `Got "abc" ("foo" unmapped), expected "adc" ("bar" unmapped)`, }, { name: "Two-line word diff", args: [`${longPrefix} eta`, `${longPrefix} theta`], - expect: ` Actual: "${longPrefix} eta" - Expected: "${longPrefix} theta"`, + expect: ` Actual: "${longPrefix} eta" + Expected: "${longPrefix} theta"`, }, { name: "Two-line word diff with unmapped values", args: [`${longPrefix} eta`, `${longPrefix} theta`, { actual: "foo", expected: "bar" }], - expect: ` Actual: "${longPrefix} eta" + expect: ` Actual: "${longPrefix} eta" "foo" unmapped - Expected: "${longPrefix} theta" + Expected: "${longPrefix} theta" "bar" unmapped`, }, { name: "Inline multiline string diff", args: ["one\ntwo\nthree", "one\nTWO\nthree"], - expect: `Got "one\\ntwo\\nthree", expected "one\\nTWO\\nthree"`, + expect: `Got "one\\ntwo\\nthree", expected "one\\nTWO\\nthree"`, }, { name: "Elided array hunks", @@ -72,15 +72,15 @@ export default { … 5 matching lines … "line4", "line5", -- \t"X", -+ \t"x", +- \t"X", ++ \t"x", "line7", "line8", … 3 matching lines … "line12", "line13", -- \t"Y", -+ \t"y", +- \t"Y", ++ \t"y", "line15", "line16", ]`, @@ -100,8 +100,8 @@ export default { args: ["foo\nbar\n", "foo\nbaz\n", { actual: "bar", expected: "yolo" }], expect: ` Actual ↔ Expected: foo -- bar -+ baz +- bar ++ baz Actual unmapped: "bar" Expected unmapped: "yolo"`, }, @@ -109,39 +109,39 @@ export default { name: "multiple paired lines", args: ["foo13\nbar42\n", "foo25\nbar47\n"], expect: ` Actual ↔ Expected: -- foo13 -+ foo25 -- bar42 -+ bar47`, +- foo13 ++ foo25 +- bar42 ++ bar47`, }, { name: "noisy swap collapses via cleanup", args: ["fix: button alignment\n", "fix: button padding\n"], expect: ` Actual ↔ Expected: -- fix: button alignment -+ fix: button padding`, +- fix: button alignment ++ fix: button padding`, }, { name: "unequal counts stay plain", args: ["foo\nbar\n", "baz\n"], expect: ` Actual ↔ Expected: -- foo -- bar -+ baz`, +- foo +- bar ++ baz`, }, { name: "added line", args: ["foo\n", "foo\nbar\n"], expect: ` Actual ↔ Expected: foo -+ bar`, ++ bar`, }, { name: "removed line", args: ["foo\nbar\n", "foo\n"], expect: ` Actual ↔ Expected: foo -- bar`, +- bar`, }, ], }, From 7376003af37d7b65ca6c7c8bd7b49d58eaa1efad Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 22:05:10 +0200 Subject: [PATCH 24/27] Add -tint semantic color tokens; migrate TestResult badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-tier semantic palette: - pass/fail/skip/message — solid variant. - pass-tint/fail-tint/skip-tint/message-tint — muted variant for secondary text (test names, etc.). Rename diff-added-emph/diff-removed-emph → diff-added-tint/diff-removed-tint for consistency with the new suffix. TestResult.js: - Badge bg uses ; label uses (passes WCAG on all three bg colors). - Test name uses . - Summary: //. - Messages header: . SKIP badge color changes from yellow to gray (skip = #a0a8b4). --- src/classes/TestResult.js | 12 +++++----- src/util/format-diff.js | 4 ++-- src/util/palette.js | 8 +++++-- tests/format-diff.js | 48 +++++++++++++++++++-------------------- 4 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/classes/TestResult.js b/src/classes/TestResult.js index d045142..bc5e723 100644 --- a/src/classes/TestResult.js +++ b/src/classes/TestResult.js @@ -358,11 +358,11 @@ ${ this.error.stack }`); * @returns {string} */ getResult (o) { - let color = this.pass ? "green" : this.skipped ? "yellow" : "red"; + let color = this.pass ? "pass" : this.skipped ? "skip" : "fail"; let label = this.pass ? "PASS" : this.skipped ? "SKIP" : "FAIL"; let ret = [ - ` ${ label } `, - `${this.name ?? "(Anonymous)"}`, + ` ${ label } `, + `${this.name ?? "(Anonymous)"}`, ].join(" "); if (this.messages?.length > 0) { @@ -392,11 +392,11 @@ ${ this.error.stack }`); ]; if (stats.pass > 0) { - ret.push(`${ stats.pass }/${ stats.total } PASS`); + ret.push(`${ stats.pass }/${ stats.total } PASS`); } if (stats.fail > 0) { - ret.push(`${ stats.fail }/${ stats.total } FAIL`); + ret.push(`${ stats.fail }/${ stats.total } FAIL`); } if (stats.pending > 0) { @@ -431,7 +431,7 @@ ${ this.error.stack }`); * @returns {string} */ getMessages (o = {}) { - let ret = new String("(Messages)"); + let ret = new String("(Messages)"); ret.children = this.messages.map(m => `(${ m.method }) ${ m.args.map(a => stringify(a)).join(" ") }`); return o?.format === "rich" ? ret : stripFormatting(ret); diff --git a/src/util/format-diff.js b/src/util/format-diff.js index f18cb99..3de6e14 100644 --- a/src/util/format-diff.js +++ b/src/util/format-diff.js @@ -20,8 +20,8 @@ const INLINE_MAX = 40; /** Per-side chunk/line bg tokens, change-action key, and output label for diff formatting. */ const sides = { - actual: { chunk: "diff-removed-emph", line: "diff-removed", action: "removed", label: " Actual: " }, - expected: { chunk: "diff-added-emph", line: "diff-added", action: "added", label: " Expected: " }, + actual: { chunk: "diff-removed-tint", line: "diff-removed", action: "removed", label: " Actual: " }, + expected: { chunk: "diff-added-tint", line: "diff-added", action: "added", label: " Expected: " }, }; /** diff --git a/src/util/palette.js b/src/util/palette.js index ce64dbe..0b923ee 100644 --- a/src/util/palette.js +++ b/src/util/palette.js @@ -24,15 +24,19 @@ const base = { const semantic = { pass: base.green, + "pass-tint": base.lightgreen, fail: base.red, + "fail-tint": base.lightred, skip: "#a0a8b4", + "skip-tint": "#c4c9d2", message: base.yellow, + "message-tint": base.lightyellow, highlight: base.green, text: base.black, "diff-added": "#2e4b3a", - "diff-added-emph": base.lightgreen, + "diff-added-tint": base.lightgreen, "diff-removed": "#4b2e38", - "diff-removed-emph": base.lightred, + "diff-removed-tint": base.lightred, gutter: "#313244", }; diff --git a/tests/format-diff.js b/tests/format-diff.js index cd3bf87..ddf2b80 100644 --- a/tests/format-diff.js +++ b/tests/format-diff.js @@ -32,31 +32,31 @@ export default { { name: "Inline char diff", args: ["abc", "adc"], - expect: `Got "abc", expected "adc"`, + expect: `Got "abc", expected "adc"`, }, { name: "Inline char diff with unmapped values", args: ["abc", "adc", { actual: "foo", expected: "bar" }], - expect: `Got "abc" ("foo" unmapped), expected "adc" ("bar" unmapped)`, + expect: `Got "abc" ("foo" unmapped), expected "adc" ("bar" unmapped)`, }, { name: "Two-line word diff", args: [`${longPrefix} eta`, `${longPrefix} theta`], - expect: ` Actual: "${longPrefix} eta" - Expected: "${longPrefix} theta"`, + expect: ` Actual: "${longPrefix} eta" + Expected: "${longPrefix} theta"`, }, { name: "Two-line word diff with unmapped values", args: [`${longPrefix} eta`, `${longPrefix} theta`, { actual: "foo", expected: "bar" }], - expect: ` Actual: "${longPrefix} eta" + expect: ` Actual: "${longPrefix} eta" "foo" unmapped - Expected: "${longPrefix} theta" + Expected: "${longPrefix} theta" "bar" unmapped`, }, { name: "Inline multiline string diff", args: ["one\ntwo\nthree", "one\nTWO\nthree"], - expect: `Got "one\\ntwo\\nthree", expected "one\\nTWO\\nthree"`, + expect: `Got "one\\ntwo\\nthree", expected "one\\nTWO\\nthree"`, }, { name: "Elided array hunks", @@ -72,15 +72,15 @@ export default { … 5 matching lines … "line4", "line5", -- \t"X", -+ \t"x", +- \t"X", ++ \t"x", "line7", "line8", … 3 matching lines … "line12", "line13", -- \t"Y", -+ \t"y", +- \t"Y", ++ \t"y", "line15", "line16", ]`, @@ -100,8 +100,8 @@ export default { args: ["foo\nbar\n", "foo\nbaz\n", { actual: "bar", expected: "yolo" }], expect: ` Actual ↔ Expected: foo -- bar -+ baz +- bar ++ baz Actual unmapped: "bar" Expected unmapped: "yolo"`, }, @@ -109,39 +109,39 @@ export default { name: "multiple paired lines", args: ["foo13\nbar42\n", "foo25\nbar47\n"], expect: ` Actual ↔ Expected: -- foo13 -+ foo25 -- bar42 -+ bar47`, +- foo13 ++ foo25 +- bar42 ++ bar47`, }, { name: "noisy swap collapses via cleanup", args: ["fix: button alignment\n", "fix: button padding\n"], expect: ` Actual ↔ Expected: -- fix: button alignment -+ fix: button padding`, +- fix: button alignment ++ fix: button padding`, }, { name: "unequal counts stay plain", args: ["foo\nbar\n", "baz\n"], expect: ` Actual ↔ Expected: -- foo -- bar -+ baz`, +- foo +- bar ++ baz`, }, { name: "added line", args: ["foo\n", "foo\nbar\n"], expect: ` Actual ↔ Expected: foo -+ bar`, ++ bar`, }, { name: "removed line", args: ["foo\nbar\n", "foo\n"], expect: ` Actual ↔ Expected: foo -- bar`, +- bar`, }, ], }, From 12cfe9a71013d8fef3369702237738752cea1219 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 24 Apr 2026 22:18:27 +0200 Subject: [PATCH 25/27] Derive badge color from label via toLowerCase --- src/classes/TestResult.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/classes/TestResult.js b/src/classes/TestResult.js index bc5e723..6a27753 100644 --- a/src/classes/TestResult.js +++ b/src/classes/TestResult.js @@ -358,8 +358,8 @@ ${ this.error.stack }`); * @returns {string} */ getResult (o) { - let color = this.pass ? "pass" : this.skipped ? "skip" : "fail"; let label = this.pass ? "PASS" : this.skipped ? "SKIP" : "FAIL"; + let color = label.toLowerCase(); let ret = [ ` ${ label } `, `${this.name ?? "(Anonymous)"}`, From 00fb4f2c88805f4e0c84979ab232ad32617c7bba Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 28 Apr 2026 15:07:26 +0200 Subject: [PATCH 26/27] Derive format-console test expectations from palette --- src/util/format-console.js | 4 +-- tests/format-console.js | 59 +++++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/util/format-console.js b/src/util/format-console.js index 4e81ee2..104edc4 100644 --- a/src/util/format-console.js +++ b/src/util/format-console.js @@ -78,7 +78,7 @@ function parseHex (hex) { }; } -function ansiTruecolor (hex, { bg } = {}) { +export function ansiTruecolor (hex, { bg } = {}) { let { r, g, b } = parseHex(hex); return `\x1b[${bg ? 48 : 38};2;${r};${g};${b}m`; } @@ -99,7 +99,7 @@ function quantize (value) { return best; } -function ansi256 (hex, { bg } = {}) { +export function ansi256 (hex, { bg } = {}) { let { r, g, b } = parseHex(hex); let index = 16 + 36 * quantize(r) + 6 * quantize(g) + quantize(b); return `\x1b[${bg ? 48 : 38};5;${index}m`; diff --git a/tests/format-console.js b/tests/format-console.js index 5afc0ab..0bc0e3d 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -1,4 +1,9 @@ -import format, { stripFormatting } from "../src/util/format-console.js"; +import format, { + stripFormatting, + ansiTruecolor, + ansi256, +} from "../src/util/format-console.js"; +import palette from "../src/util/palette.js"; // Escape ANSI escape codes so failure output shows them as visible characters. // Mirrors previous convention in this file — avoids `map`, which would display @@ -7,6 +12,11 @@ function escape (str) { return str.replaceAll("\x1b", "\\x1b"); } +const RESET = "\\x1b[0m"; +const color = hex => escape(ansiTruecolor(hex)); +const bgColor = hex => escape(ansiTruecolor(hex, { bg: true })); +const color256 = hex => escape(ansi256(hex)); + export default { name: "format-console", tests: [ @@ -24,12 +34,12 @@ export default { { name: "Bold modifier", arg: "x", - expect: "\\x1b[1mx\\x1b[0m", + expect: `\\x1b[1mx${RESET}`, }, { name: "Semantic token", arg: "x", - expect: "\\x1b[38;2;74;222;128mx\\x1b[0m", + expect: `${color(palette.pass)}x${RESET}`, }, { name: "Hex literal", @@ -39,30 +49,27 @@ export default { { name: "Background", arg: "x", - expect: "\\x1b[48;2;74;222;128mx\\x1b[0m", + expect: `${bgColor(palette.pass)}x${RESET}`, }, { name: "Nested preserves outer", arg: "xy", - expect: - "\\x1b[38;2;74;222;128m\\x1b[38;2;243;139;168mx\\x1b[0m\\x1b[38;2;74;222;128my\\x1b[0m", + expect: `${color(palette.pass)}${color(palette.fail)}x${RESET}${color(palette.pass)}y${RESET}`, }, { name: "Diff-style", arg: " + added - removed", - expect: - "\\x1b[48;2;49;50;68m \\x1b[38;2;46;75;58m+ added\\x1b[0m\\x1b[48;2;49;50;68m \\x1b[38;2;75;46;56m- removed\\x1b[0m\\x1b[48;2;49;50;68m\\x1b[0m", + expect: `${bgColor(palette.gutter)} ${color(palette["diff-added"])}+ added${RESET}${bgColor(palette.gutter)} ${color(palette["diff-removed"])}- removed${RESET}${bgColor(palette.gutter)}${RESET}`, }, { name: "Unknown color ignored", arg: "x", - expect: "x\\x1b[0m", + expect: `x${RESET}`, }, { name: "Nested unknown preserves outer", arg: "x", - expect: - "\\x1b[38;2;74;222;128m\\x1b[1mx\\x1b[0m\\x1b[38;2;74;222;128m\\x1b[0m\\x1b[38;2;74;222;128m\\x1b[0m", + expect: `${color(palette.pass)}\\x1b[1mx${RESET}${color(palette.pass)}${RESET}${color(palette.pass)}${RESET}`, }, { name: "3-char hex expansion", @@ -81,9 +88,9 @@ export default { data: { mode: "256" }, tests: [ { - name: "Semantic token (pass → #4ade80 → index 78)", + name: "Semantic token via 256 cube", arg: "x", - expect: "\\x1b[38;5;78mx\\x1b[0m", + expect: `${color256(palette.pass)}x${RESET}`, }, { name: "Hex literal red → index 196", @@ -99,17 +106,17 @@ export default { { name: "Semantic token stripped", arg: "x", - expect: "x\\x1b[0m", + expect: `x${RESET}`, }, { name: "Hex literal stripped", arg: "x", - expect: "x\\x1b[0m", + expect: `x${RESET}`, }, { name: "Colors stripped, modifiers kept", arg: "x", - expect: "\\x1b[1mx\\x1b[0m\\x1b[1m\\x1b[0m", + expect: `\\x1b[1mx${RESET}\\x1b[1m${RESET}`, }, ], }, @@ -120,16 +127,16 @@ export default { { name: "Single foreground", arg: "x", - expect: ["%cx%c", "color: #4ade80", ""], + expect: ["%cx%c", `color: ${palette.pass}`, ""], }, { name: "Nested foreground", arg: "x", expect: [ "%c%cx%c%c", - "color: #4ade80", - "color: #f38ba8", - "color: #4ade80", + `color: ${palette.pass}`, + `color: ${palette.fail}`, + `color: ${palette.pass}`, "", ], }, @@ -139,7 +146,7 @@ export default { expect: [ "%c%cx%c%c", "font-weight: bold", - "font-weight: bold; background: #4ade80", + `font-weight: bold; background: ${palette.pass}`, "font-weight: bold", "", ], @@ -149,11 +156,11 @@ export default { arg: " + added - removed", expect: [ "%c %c+ added%c %c- removed%c%c", - "background: #313244", - "color: #2e4b3a; background: #313244", - "background: #313244", - "color: #4b2e38; background: #313244", - "background: #313244", + `background: ${palette.gutter}`, + `color: ${palette["diff-added"]}; background: ${palette.gutter}`, + `background: ${palette.gutter}`, + `color: ${palette["diff-removed"]}; background: ${palette.gutter}`, + `background: ${palette.gutter}`, "", ], }, From d5f24d08d0becb174bc02a1d3e1ff144a80188aa Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 28 Apr 2026 15:13:52 +0200 Subject: [PATCH 27/27] Trim escape() comment --- tests/format-console.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/format-console.js b/tests/format-console.js index 0bc0e3d..be39afc 100644 --- a/tests/format-console.js +++ b/tests/format-console.js @@ -6,8 +6,8 @@ import format, { import palette from "../src/util/palette.js"; // Escape ANSI escape codes so failure output shows them as visible characters. -// Mirrors previous convention in this file — avoids `map`, which would display -// unmapped raw values alongside, creating unreadable diffs. +// We don't want to use map because it will output unmapped values on fail as +// well, causing a mess in this very special case. function escape (str) { return str.replaceAll("\x1b", "\\x1b"); }