From 931d263dc97ac4903563293b008864a739cf3bf8 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 2 Aug 2026 13:41:37 +0200 Subject: [PATCH 1/6] util: downgrade hex colors to terminal color depth styleText() always emitted 24-bit TrueColor sequences for hex colors, even when the terminal only supports 16 or 256 colors. Terminals that do not understand the sequence render it as literal text or drop the color. Emit the sequence matching the color depth reported by the stream, or by FORCE_COLOR when it is set: TrueColor for level 3, the closest entry of the 256-color palette for level 2, and the closest of the 16 basic colors for level 1. Named formats are unaffected, as they already are basic color codes. When validateStream is false there is no stream to inspect, so the color is only downgraded if FORCE_COLOR is set. The conversions mirror the ones used by ansis and ansi-styles, so a hex color downgrades to the same color the rest of the ecosystem picks. Refs: https://nodejs.org/api/cli.html#force_color1-2-3 Signed-off-by: Tobbe Lundberg --- doc/api/util.md | 19 +++ lib/internal/util/colors.js | 39 +++++- lib/util.js | 97 ++++++++++++-- test/parallel/test-util-styletext-hex.js | 162 +++++++++++++++++++++-- 4 files changed, 292 insertions(+), 25 deletions(-) diff --git a/doc/api/util.md b/doc/api/util.md index fd93b5272293..e46eb6735f16 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -2548,6 +2548,10 @@ added: - v21.7.0 - v20.12.0 changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/64955 + description: Hexadecimal colors are downgraded to the color depth + supported by the terminal. - version: - v26.1.0 - v24.16.0 @@ -2660,6 +2664,20 @@ console.log(styleText('#ff5733', 'Orange text')); console.log(styleText('#f00', 'Red text')); ``` +Hex colors are emitted with the highest color depth the terminal supports. When +the terminal, or the [`FORCE_COLOR`][] environment variable, reports fewer than +16 million colors, the color is downgraded to the closest color available: + +* `FORCE_COLOR=3`, or a terminal supporting 16 million colors: TrueColor + (24-bit) escape sequences, for example ``. +* `FORCE_COLOR=2`, or a terminal supporting 256 colors: the closest color of the + 256-color palette, for example ``. +* `FORCE_COLOR=1`, or a terminal supporting 16 colors: the closest of the 16 + basic colors, for example ``. + +When `validateStream` is `false`, hex colors are only downgraded if +`FORCE_COLOR` is set, since no stream is inspected to determine the color depth. + The full list of formats can be found in [modifiers][]. ## Class: `util.TextDecoder` @@ -3907,6 +3925,7 @@ npx codemod@latest @nodejs/util-is [`Array.isArray()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray [`ArrayBuffer.isView()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer/isView [`Error.isError`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/isError +[`FORCE_COLOR`]: cli.md#force_color1-2-3 [`JSON.stringify()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify [`MIMEparams`]: #class-utilmimeparams [`Object.assign()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign diff --git a/lib/internal/util/colors.js b/lib/internal/util/colors.js index 0b37694b513f..6c59593a07a6 100644 --- a/lib/internal/util/colors.js +++ b/lib/internal/util/colors.js @@ -6,6 +6,11 @@ function lazyInternalTTY() { return internalTTy; } +// Color depths in bits, matching the values returned by `getColorDepth()`. +const COLORS_2 = 1; +const COLORS_16 = 4; +const COLORS_16m = 24; + module.exports = { blue: '', green: '', @@ -15,13 +20,37 @@ module.exports = { clear: '', reset: '', hasColors: false, - shouldColorize(stream) { + // Number of bits of color the stream supports, as reported by + // `tty.WriteStream.prototype.getColorDepth()`. `FORCE_COLOR` takes precedence + // over the stream, since it describes the terminal the output ends up in. + getColorDepth(stream) { if (process.env.FORCE_COLOR !== undefined) { - return lazyInternalTTY().getColorDepth() > 2; + return lazyInternalTTY().getColorDepth(); + } + + if (!stream?.isTTY) { + return COLORS_2; } - return stream?.isTTY && ( - typeof stream.getColorDepth === 'function' ? - stream.getColorDepth() > 2 : true); + + return typeof stream.getColorDepth === 'function' ? + stream.getColorDepth() : + COLORS_16; + }, + // Depth to assume when the stream is not validated. `FORCE_COLOR` is then the + // only hint about the terminal capabilities; without it, assume that whoever + // opted out of the stream validation can handle the full color range. + getForcedColorDepth() { + if (process.env.FORCE_COLOR === undefined) { + return COLORS_16m; + } + + const depth = lazyInternalTTY().getColorDepth(); + + // `FORCE_COLOR=0` does not disable colors when the stream is not validated. + return depth > COLORS_2 ? depth : COLORS_16m; + }, + shouldColorize(stream) { + return module.exports.getColorDepth(stream) > 2; }, refresh() { if (module.exports.shouldColorize(process.stderr)) { diff --git a/lib/util.js b/lib/util.js index e828229380d9..88c244e27154 100644 --- a/lib/util.js +++ b/lib/util.js @@ -28,6 +28,8 @@ const { Error, ErrorCaptureStackTrace, FunctionPrototypeBind, + MathMax, + MathRound, NumberIsSafeInteger, ObjectDefineProperties, ObjectDefineProperty, @@ -116,9 +118,13 @@ const kEscapeEnd = 'm'; const kDimCode = 2; const kBoldCode = 1; -// Close sequence for 24-bit foreground colors (reset to default foreground) +// Close sequence for hex foreground colors (reset to default foreground) const kHexCloseSeq = kEscape + '39' + kEscapeEnd; +// Color depths in bits, matching the values returned by `getColorDepth()`. +const kColorDepth256 = 8; +const kColorDepth16m = 24; + let styleCache; const kHexStyleCacheMax = 256; @@ -155,19 +161,23 @@ function getStyleCache() { } /** - * Returns the cached ANSI escape sequences for a hex color. - * Computes and caches on first use to avoid repeated Buffer allocations. + * Returns the cached ANSI escape sequences for a hex color, one per supported + * color depth. Computes and caches on first use to avoid repeated Buffer + * allocations. * @param {string} hex A valid hex color string (#RGB or #RRGGBB) - * @returns {{openSeq: string, closeSeq: string}} + * @returns {{openSeq: string, openSeq256: string, openSeq16: string, closeSeq: string}} */ function getHexStyle(hex) { const cache = getHexStyleCache(); const cached = cache.get(hex); if (cached !== undefined) return cached; const { 0: r, 1: g, 2: b } = hexToRgb(hex); + const ansi256 = rgbToAnsi256(r, g, b); const style = { __proto__: null, openSeq: kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd, + openSeq256: `${kEscape}38;5;${ansi256}${kEscapeEnd}`, + openSeq16: kEscape + ansi256To16(ansi256) + kEscapeEnd, closeSeq: kHexCloseSeq, }; if (cache.size >= kHexStyleCacheMax) @@ -176,6 +186,19 @@ function getHexStyle(hex) { return style; } +/** + * Picks the hex color escape sequence matching the color depth of the terminal, + * downgrading 24-bit colors to the closest 256-color or 16-color equivalent. + * @param {{openSeq: string, openSeq256: string, openSeq16: string}} style + * @param {number} colorDepth Number of bits of color supported + * @returns {string} The ANSI escape sequence + */ +function hexOpenSeq(style, colorDepth) { + if (colorDepth >= kColorDepth16m) return style.openSeq; + if (colorDepth >= kColorDepth256) return style.openSeq256; + return style.openSeq16; +} + function replaceCloseCode(str, closeSeq, openSeq, keepClose) { const closeLen = closeSeq.length; let index = str.indexOf(closeSeq); @@ -235,6 +258,54 @@ function rgbToAnsi24Bit(r, g, b) { return `38;2;${r};${g};${b}`; } +/** + * Converts RGB values to the closest color code of the ANSI 256-color palette. + * @param {number} r Red component (0-255) + * @param {number} g Green component (0-255) + * @param {number} b Blue component (0-255) + * @returns {number} The color code (16-255) + */ +function rgbToAnsi256(r, g, b) { + // Faster equivalent of `r !== g || g !== b`. + if (r ^ g | g ^ b) { + // 6x6x6 color cube (16-231). `c / 255 * 5` is the same as `c / 51`. + return 16 + 36 * MathRound(r / 51) + 6 * MathRound(g / 51) + MathRound(b / 51); + } + + // Grayscale ramp (232-255), with both ends of the color cube as bounds. + if (r < 8) return 16; + if (r > 248) return 231; + return MathRound(((r - 8) * 24) / 247) + 232; +} + +/** + * Converts an ANSI 256-color code to the closest of the 16 basic colors. + * @param {number} code The color code (0-255) + * @returns {number} The foreground color code (30-37 or 90-97) + */ +function ansi256To16(code) { + if (code < 8) return 30 + code; + if (code < 16) return 82 + code; + // Grayscale (232-255) is either black or white, flipping at the middle of the + // ramp. + if (code > 231) return code > 243 ? 37 : 30; + + // Color cube (16-231). + code -= 16; + const remainder = code % 36; + // The channels are integers 0-5; `n | 0` is a faster `MathFloor(n)`. + const r = (code / 36) | 0; + const g = (remainder / 6) | 0; + const b = remainder % 6; + + // A channel is on when it is greater than 2, which is `MathRound(c / 5)`. + // The bits are packed red first, matching the basic color codes. + const color = (r > 2) | ((g > 2) << 1) | ((b > 2) << 2); + + // A fully saturated channel switches to the bright variant (90-97). + return 30 + color + (MathMax(r, g, b) > 4 ? 60 : 0); +} + /** * @param {string | string[]} format * @param {string} text @@ -262,8 +333,9 @@ function styleText(format, text, options) { hexStyle = getHexStyle(format); } if (hexStyle !== undefined) { - const processed = replaceCloseCode(text, hexStyle.closeSeq, hexStyle.openSeq, false); - return hexStyle.openSeq + processed + hexStyle.closeSeq; + const openSeq = hexOpenSeq(hexStyle, lazyUtilColors().getForcedColorDepth()); + const processed = replaceCloseCode(text, hexStyle.closeSeq, openSeq, false); + return openSeq + processed + hexStyle.closeSeq; } } } @@ -275,6 +347,7 @@ function styleText(format, text, options) { validateBoolean(validateStream, 'options.validateStream'); let skipColorize; + let colorDepth; if (validateStream) { const stream = options?.stream ?? process.stdout; if ( @@ -284,7 +357,10 @@ function styleText(format, text, options) { ) { throw new ERR_INVALID_ARG_TYPE('stream', ['ReadableStream', 'WritableStream', 'Stream'], stream); } - skipColorize = !lazyUtilColors().shouldColorize(stream); + colorDepth = lazyUtilColors().getColorDepth(stream); + skipColorize = colorDepth <= 2; + } else { + colorDepth = lazyUtilColors().getForcedColorDepth(); } const formatArray = ArrayIsArray(format) ? format : [format]; @@ -303,11 +379,10 @@ function styleText(format, text, options) { 'must be a valid hex color (#RGB or #RRGGBB)'); } if (skipColorize) continue; - const { 0: r, 1: g, 2: b } = hexToRgb(key); - const hexOpenSeq = kEscape + rgbToAnsi24Bit(r, g, b) + kEscapeEnd; - openCodes += hexOpenSeq; + const openSeq = hexOpenSeq(getHexStyle(key), colorDepth); + openCodes += openSeq; closeCodes = kHexCloseSeq + closeCodes; - processedText = replaceCloseCode(processedText, kHexCloseSeq, hexOpenSeq, false); + processedText = replaceCloseCode(processedText, kHexCloseSeq, openSeq, false); continue; } diff --git a/test/parallel/test-util-styletext-hex.js b/test/parallel/test-util-styletext-hex.js index f12c35a780d6..1607044094cf 100644 --- a/test/parallel/test-util-styletext-hex.js +++ b/test/parallel/test-util-styletext-hex.js @@ -6,6 +6,10 @@ const { describe, it } = require('node:test'); const util = require('node:util'); const { WriteStream } = require('node:tty'); +// Hex colors are downgraded to the color depth reported by `FORCE_COLOR`, so +// make sure the environment running the test does not set it. +delete process.env.FORCE_COLOR; + describe('util.styleText hex color support', () => { describe('valid 6-digit hex colors', () => { it('should parse #ffcc00 as RGB(255, 204, 0)', () => { @@ -143,9 +147,19 @@ describe('util.styleText hex color support', () => { }); describe('environment variable behavior', () => { + // #ffcc00 in each of the supported color depths. const styledHex = '\u001b[38;2;255;204;0mtest\u001b[39m'; + const styledHex256 = '\u001b[38;5;220mtest\u001b[39m'; + const styledHex16 = '\u001b[93mtest\u001b[39m'; const noChange = 'test'; + // The output expected from a terminal supporting `depth` bits of color. + function styledForDepth(depth) { + if (depth >= 24) return styledHex; + if (depth >= 8) return styledHex256; + return styledHex16; + } + const fd = common.getTTYfd(); if (fd === -1) { it.skip('Could not create TTY fd', () => {}); @@ -157,7 +171,8 @@ describe('util.styleText hex color support', () => { { isTTY: true, env: {}, - expected: styledHex, + // Depends on the color depth of the terminal running the test. + expected: () => styledForDepth(writeStream.getColorDepth()), description: 'isTTY=true with no env vars', }, { @@ -181,26 +196,56 @@ describe('util.styleText hex color support', () => { { isTTY: true, env: { FORCE_COLOR: '1' }, + expected: styledHex16, + description: 'FORCE_COLOR=1 downgrading to 16 colors', + }, + { + isTTY: true, + env: { FORCE_COLOR: 'true' }, + expected: styledHex16, + description: 'FORCE_COLOR=true downgrading to 16 colors', + }, + { + isTTY: true, + env: { FORCE_COLOR: '' }, + expected: styledHex16, + description: 'an empty FORCE_COLOR downgrading to 16 colors', + }, + { + isTTY: true, + env: { FORCE_COLOR: '2' }, + expected: styledHex256, + description: 'FORCE_COLOR=2 downgrading to 256 colors', + }, + { + isTTY: true, + env: { FORCE_COLOR: '3' }, + expected: styledHex, + description: 'FORCE_COLOR=3 keeping 24-bit colors', + }, + { + isTTY: false, + env: { FORCE_COLOR: '3' }, expected: styledHex, - description: 'FORCE_COLOR=1', + description: 'FORCE_COLOR=3 with isTTY=false', }, { isTTY: true, env: { FORCE_COLOR: '1', NODE_DISABLE_COLORS: '1' }, - expected: styledHex, + expected: styledHex16, description: 'FORCE_COLOR=1 overrides NODE_DISABLE_COLORS', }, { isTTY: false, - env: { FORCE_COLOR: '1', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' }, - expected: styledHex, - description: 'FORCE_COLOR=1 overrides all disable flags', + env: { FORCE_COLOR: '2', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' }, + expected: styledHex256, + description: 'FORCE_COLOR=2 overrides all disable flags', }, { isTTY: true, - env: { FORCE_COLOR: '1', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' }, + env: { FORCE_COLOR: '3', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' }, expected: styledHex, - description: 'FORCE_COLOR=1 wins with all flags', + description: 'FORCE_COLOR=3 wins with all flags', }, { isTTY: true, @@ -217,14 +262,113 @@ describe('util.styleText hex color support', () => { ...originalEnv, ...testCase.env, }; + const expected = typeof testCase.expected === 'function' ? + testCase.expected() : + testCase.expected; const output = util.styleText('#ffcc00', 'test', { stream: writeStream }); - assert.strictEqual(output, testCase.expected); + assert.strictEqual(output, expected); + // Combining the hex color with another format applies the same depth. + const combined = util.styleText(['bold', '#ffcc00'], 'test', { stream: writeStream }); + assert.strictEqual( + combined, + expected === noChange ? noChange : `\u001b[1m${expected}\u001b[22m`, + ); process.env = originalEnv; }); } } }); + describe('color depth downgrade without stream validation', () => { + const originalEnv = { ...process.env }; + + function styled(format, forceColor) { + process.env = { ...originalEnv }; + if (forceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = forceColor; + } + try { + return util.styleText(format, 'test', { validateStream: false }); + } finally { + process.env = originalEnv; + } + } + + it('should keep 24-bit colors when FORCE_COLOR is not set', () => { + assert.strictEqual(styled('#ffcc00'), '\u001b[38;2;255;204;0mtest\u001b[39m'); + }); + + it('should downgrade to 16 colors with FORCE_COLOR=1', () => { + assert.strictEqual(styled('#ffcc00', '1'), '\u001b[93mtest\u001b[39m'); + }); + + it('should downgrade to 256 colors with FORCE_COLOR=2', () => { + assert.strictEqual(styled('#ffcc00', '2'), '\u001b[38;5;220mtest\u001b[39m'); + }); + + it('should keep 24-bit colors with FORCE_COLOR=3', () => { + assert.strictEqual(styled('#ffcc00', '3'), '\u001b[38;2;255;204;0mtest\u001b[39m'); + }); + + it('should keep 24-bit colors with FORCE_COLOR=0', () => { + // Colors are not disabled when the stream is not validated. + assert.strictEqual(styled('#ffcc00', '0'), '\u001b[38;2;255;204;0mtest\u001b[39m'); + }); + + it('should downgrade every color of an array of formats', () => { + assert.strictEqual( + styled(['#ff0000', 'underline', '#00ff00'], '2'), + '\u001b[38;5;196m\u001b[4m\u001b[38;5;46mtest\u001b[39m\u001b[24m\u001b[39m', + ); + }); + }); + + describe('closest color for each depth', () => { + const originalEnv = { ...process.env }; + + function styled(format, forceColor) { + process.env = { ...originalEnv, FORCE_COLOR: forceColor }; + try { + return util.styleText(format, 'x', { validateStream: false }); + } finally { + process.env = originalEnv; + } + } + + it('should map colors to the closest of the 16 basic colors', () => { + assert.strictEqual(styled('#000000', '1'), '\u001b[30mx\u001b[39m'); + assert.strictEqual(styled('#ff0000', '1'), '\u001b[91mx\u001b[39m'); + assert.strictEqual(styled('#00ff00', '1'), '\u001b[92mx\u001b[39m'); + assert.strictEqual(styled('#0000ff', '1'), '\u001b[94mx\u001b[39m'); + assert.strictEqual(styled('#00ffff', '1'), '\u001b[96mx\u001b[39m'); + assert.strictEqual(styled('#ff00ff', '1'), '\u001b[95mx\u001b[39m'); + assert.strictEqual(styled('#ffffff', '1'), '\u001b[97mx\u001b[39m'); + assert.strictEqual(styled('#808080', '1'), '\u001b[37mx\u001b[39m'); + // Only a fully saturated channel switches to the bright variant, so + // mid-tones keep the normal colors. + assert.strictEqual(styled('#aabbcc', '1'), '\u001b[37mx\u001b[39m'); + assert.strictEqual(styled('#f0f0f0', '1'), '\u001b[37mx\u001b[39m'); + assert.strictEqual(styled('#ffcc00', '1'), '\u001b[93mx\u001b[39m'); + // Colors too dark to be told apart end up black. + assert.strictEqual(styled('#123456', '1'), '\u001b[30mx\u001b[39m'); + }); + + it('should map colors to the closest of the 256 color palette', () => { + // Both ends of the 6x6x6 color cube. + assert.strictEqual(styled('#000000', '2'), '\u001b[38;5;16mx\u001b[39m'); + assert.strictEqual(styled('#ffffff', '2'), '\u001b[38;5;231mx\u001b[39m'); + assert.strictEqual(styled('#ff0000', '2'), '\u001b[38;5;196mx\u001b[39m'); + assert.strictEqual(styled('#00ff00', '2'), '\u001b[38;5;46mx\u001b[39m'); + assert.strictEqual(styled('#0000ff', '2'), '\u001b[38;5;21mx\u001b[39m'); + // Grayscale ramp. + assert.strictEqual(styled('#080808', '2'), '\u001b[38;5;232mx\u001b[39m'); + assert.strictEqual(styled('#808080', '2'), '\u001b[38;5;244mx\u001b[39m'); + assert.strictEqual(styled('#f8f8f8', '2'), '\u001b[38;5;255mx\u001b[39m'); + }); + }); + describe('nested hex colors', () => { it('should handle nested hex color styling', () => { const inner = util.styleText('#0000ff', 'inner', { validateStream: false }); From d04046890ce1853883e534c9cbb56683c007cda0 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 2 Aug 2026 14:52:13 +0200 Subject: [PATCH 2/6] Disable colors with FORCE_COLOR=0 --- doc/api/util.md | 2 ++ lib/internal/util/colors.js | 5 +---- lib/util.js | 5 ++++- test/parallel/test-util-styletext-hex.js | 7 ++++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/doc/api/util.md b/doc/api/util.md index e46eb6735f16..eca5023aaf66 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -2677,6 +2677,8 @@ the terminal, or the [`FORCE_COLOR`][] environment variable, reports fewer than When `validateStream` is `false`, hex colors are only downgraded if `FORCE_COLOR` is set, since no stream is inspected to determine the color depth. +A `FORCE_COLOR` value that disables colors (such as `0`) disables colorized +output even when `validateStream` is `false`. The full list of formats can be found in [modifiers][]. diff --git a/lib/internal/util/colors.js b/lib/internal/util/colors.js index 6c59593a07a6..9a2743440040 100644 --- a/lib/internal/util/colors.js +++ b/lib/internal/util/colors.js @@ -44,10 +44,7 @@ module.exports = { return COLORS_16m; } - const depth = lazyInternalTTY().getColorDepth(); - - // `FORCE_COLOR=0` does not disable colors when the stream is not validated. - return depth > COLORS_2 ? depth : COLORS_16m; + return lazyInternalTTY().getColorDepth(); }, shouldColorize(stream) { return module.exports.getColorDepth(stream) > 2; diff --git a/lib/util.js b/lib/util.js index 88c244e27154..256f4839a607 100644 --- a/lib/util.js +++ b/lib/util.js @@ -333,7 +333,9 @@ function styleText(format, text, options) { hexStyle = getHexStyle(format); } if (hexStyle !== undefined) { - const openSeq = hexOpenSeq(hexStyle, lazyUtilColors().getForcedColorDepth()); + const colorDepth = lazyUtilColors().getForcedColorDepth(); + if (colorDepth <= 2) return text; + const openSeq = hexOpenSeq(hexStyle, colorDepth); const processed = replaceCloseCode(text, hexStyle.closeSeq, openSeq, false); return openSeq + processed + hexStyle.closeSeq; } @@ -361,6 +363,7 @@ function styleText(format, text, options) { skipColorize = colorDepth <= 2; } else { colorDepth = lazyUtilColors().getForcedColorDepth(); + skipColorize = colorDepth <= 2; } const formatArray = ArrayIsArray(format) ? format : [format]; diff --git a/test/parallel/test-util-styletext-hex.js b/test/parallel/test-util-styletext-hex.js index 1607044094cf..4cb1ac122281 100644 --- a/test/parallel/test-util-styletext-hex.js +++ b/test/parallel/test-util-styletext-hex.js @@ -312,9 +312,10 @@ describe('util.styleText hex color support', () => { assert.strictEqual(styled('#ffcc00', '3'), '\u001b[38;2;255;204;0mtest\u001b[39m'); }); - it('should keep 24-bit colors with FORCE_COLOR=0', () => { - // Colors are not disabled when the stream is not validated. - assert.strictEqual(styled('#ffcc00', '0'), '\u001b[38;2;255;204;0mtest\u001b[39m'); + it('should disable colors with FORCE_COLOR=0', () => { + assert.strictEqual(styled('#ffcc00', '0'), 'test'); + // Also via the array path, which goes through the shared loop. + assert.strictEqual(styled(['bold', '#ffcc00'], '0'), 'test'); }); it('should downgrade every color of an array of formats', () => { From f4aa112a98e2149b2b5440907b2ab07e19e6efef Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 2 Aug 2026 15:12:55 +0200 Subject: [PATCH 3/6] hoist skipColrize assignment --- lib/util.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/util.js b/lib/util.js index 256f4839a607..f371e9fb4397 100644 --- a/lib/util.js +++ b/lib/util.js @@ -348,7 +348,6 @@ function styleText(format, text, options) { } validateBoolean(validateStream, 'options.validateStream'); - let skipColorize; let colorDepth; if (validateStream) { const stream = options?.stream ?? process.stdout; @@ -360,12 +359,11 @@ function styleText(format, text, options) { throw new ERR_INVALID_ARG_TYPE('stream', ['ReadableStream', 'WritableStream', 'Stream'], stream); } colorDepth = lazyUtilColors().getColorDepth(stream); - skipColorize = colorDepth <= 2; } else { colorDepth = lazyUtilColors().getForcedColorDepth(); - skipColorize = colorDepth <= 2; } + const skipColorize = colorDepth <= 2; const formatArray = ArrayIsArray(format) ? format : [format]; const colors = inspect.colors; From 804f1ef8ee8ce4c60c0c215f77e7d4257c5d5a6d Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 2 Aug 2026 15:22:16 +0200 Subject: [PATCH 4/6] export color depths --- lib/internal/util/colors.js | 5 +++++ lib/util.js | 11 ++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/internal/util/colors.js b/lib/internal/util/colors.js index 9a2743440040..f28186981b95 100644 --- a/lib/internal/util/colors.js +++ b/lib/internal/util/colors.js @@ -9,6 +9,7 @@ function lazyInternalTTY() { // Color depths in bits, matching the values returned by `getColorDepth()`. const COLORS_2 = 1; const COLORS_16 = 4; +const COLORS_256 = 8; const COLORS_16m = 24; module.exports = { @@ -20,6 +21,10 @@ module.exports = { clear: '', reset: '', hasColors: false, + COLORS_2, + COLORS_16, + COLORS_256, + COLORS_16m, // Number of bits of color the stream supports, as reported by // `tty.WriteStream.prototype.getColorDepth()`. `FORCE_COLOR` takes precedence // over the stream, since it describes the terminal the output ends up in. diff --git a/lib/util.js b/lib/util.js index f371e9fb4397..4c2c2537c228 100644 --- a/lib/util.js +++ b/lib/util.js @@ -121,10 +121,6 @@ const kBoldCode = 1; // Close sequence for hex foreground colors (reset to default foreground) const kHexCloseSeq = kEscape + '39' + kEscapeEnd; -// Color depths in bits, matching the values returned by `getColorDepth()`. -const kColorDepth256 = 8; -const kColorDepth16m = 24; - let styleCache; const kHexStyleCacheMax = 256; @@ -194,8 +190,9 @@ function getHexStyle(hex) { * @returns {string} The ANSI escape sequence */ function hexOpenSeq(style, colorDepth) { - if (colorDepth >= kColorDepth16m) return style.openSeq; - if (colorDepth >= kColorDepth256) return style.openSeq256; + const { COLORS_256, COLORS_16m } = lazyUtilColors(); + if (colorDepth >= COLORS_16m) return style.openSeq; + if (colorDepth >= COLORS_256) return style.openSeq256; return style.openSeq16; } @@ -362,8 +359,8 @@ function styleText(format, text, options) { } else { colorDepth = lazyUtilColors().getForcedColorDepth(); } - const skipColorize = colorDepth <= 2; + const formatArray = ArrayIsArray(format) ? format : [format]; const colors = inspect.colors; From 9a658d91cdc4cc7807e55552418f242154132fb9 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 2 Aug 2026 15:33:37 +0200 Subject: [PATCH 5/6] no magic numbers --- lib/internal/util/colors.js | 2 +- lib/util.js | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/internal/util/colors.js b/lib/internal/util/colors.js index f28186981b95..156ec0b8fc72 100644 --- a/lib/internal/util/colors.js +++ b/lib/internal/util/colors.js @@ -52,7 +52,7 @@ module.exports = { return lazyInternalTTY().getColorDepth(); }, shouldColorize(stream) { - return module.exports.getColorDepth(stream) > 2; + return module.exports.getColorDepth(stream) >= COLORS_16; }, refresh() { if (module.exports.shouldColorize(process.stderr)) { diff --git a/lib/util.js b/lib/util.js index 4c2c2537c228..0c6bdc9de383 100644 --- a/lib/util.js +++ b/lib/util.js @@ -330,8 +330,9 @@ function styleText(format, text, options) { hexStyle = getHexStyle(format); } if (hexStyle !== undefined) { - const colorDepth = lazyUtilColors().getForcedColorDepth(); - if (colorDepth <= 2) return text; + const utilColors = lazyUtilColors(); + const colorDepth = utilColors.getForcedColorDepth(); + if (colorDepth < utilColors.COLORS_16) return text; const openSeq = hexOpenSeq(hexStyle, colorDepth); const processed = replaceCloseCode(text, hexStyle.closeSeq, openSeq, false); return openSeq + processed + hexStyle.closeSeq; @@ -359,7 +360,7 @@ function styleText(format, text, options) { } else { colorDepth = lazyUtilColors().getForcedColorDepth(); } - const skipColorize = colorDepth <= 2; + const skipColorize = colorDepth < lazyUtilColors().COLORS_16; const formatArray = ArrayIsArray(format) ? format : [format]; const colors = inspect.colors; From fdc3a725cbc557f693a172388bb3f73995290c67 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Mon, 3 Aug 2026 16:14:00 +0200 Subject: [PATCH 6/6] no delete --- test/parallel/test-util-styletext-hex.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test/parallel/test-util-styletext-hex.js b/test/parallel/test-util-styletext-hex.js index 4cb1ac122281..e7ed015058d8 100644 --- a/test/parallel/test-util-styletext-hex.js +++ b/test/parallel/test-util-styletext-hex.js @@ -7,8 +7,13 @@ const util = require('node:util'); const { WriteStream } = require('node:tty'); // Hex colors are downgraded to the color depth reported by `FORCE_COLOR`, so -// make sure the environment running the test does not set it. -delete process.env.FORCE_COLOR; +// run with an environment that does not set it. Every helper below builds its +// environment from this one, which keeps the expectations independent of the +// environment running the test. +const { FORCE_COLOR, ...envWithoutForceColor } = process.env; +if (FORCE_COLOR !== undefined) { + process.env = envWithoutForceColor; +} describe('util.styleText hex color support', () => { describe('valid 6-digit hex colors', () => { @@ -283,12 +288,11 @@ describe('util.styleText hex color support', () => { const originalEnv = { ...process.env }; function styled(format, forceColor) { - process.env = { ...originalEnv }; - if (forceColor === undefined) { - delete process.env.FORCE_COLOR; - } else { - process.env.FORCE_COLOR = forceColor; - } + // `originalEnv` never has `FORCE_COLOR`, so leaving it out is enough to + // test the case where it is unset. + process.env = forceColor === undefined ? + { ...originalEnv } : + { ...originalEnv, FORCE_COLOR: forceColor }; try { return util.styleText(format, 'test', { validateStream: false }); } finally {