diff --git a/src/__tests__/constants/figuresScreenReader.test.ts b/src/__tests__/constants/figuresScreenReader.test.ts new file mode 100644 index 0000000..673825a --- /dev/null +++ b/src/__tests__/constants/figuresScreenReader.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { BLACK_CIRCLE, getFigures } from "../../constants/figures.js"; +import { resetSettingsCache } from "../../utils/settings/settingsCache.js"; + +// item 19 — screen-reader figure set. getFigures() is a lazy factory: first +// call reads getInitialSettings().prefersReducedMotion, returns a frozen +// Unicode set (default, byte-identical to existing consts) or an ASCII +// downgrade set (reducedMotion). Cached keyed on the boolean, recomputes on +// change. Existing `export const` constants are unchanged for unmigrated +// consumers — default-off behavior is preserved. + +const ENV_KEYS = ["FUSION_SCREEN_READER", "FUSION_AX_SCREEN_READER"] as const; + +function clearEnv(): void { + for (const k of ENV_KEYS) { + delete process.env[k]; + } +} + +describe("item 19 — screen-reader figure set (getFigures)", () => { + beforeEach(() => { + clearEnv(); + ( + globalThis as { __fusionScreenReaderOverride?: boolean } + ).__fusionScreenReaderOverride = undefined; + resetSettingsCache(); + }); + + afterEach(() => { + clearEnv(); + ( + globalThis as { __fusionScreenReaderOverride?: boolean } + ).__fusionScreenReaderOverride = undefined; + resetSettingsCache(); + }); + + it("default (no reduced motion) → byte-identical to existing consts", () => { + const fig = getFigures(); + expect(fig.BLACK_CIRCLE).toBe(BLACK_CIRCLE); + // UP_ARROW/EFFORT_LOW are module consts too — ensure set matches. + expect(fig.UP_ARROW).toBe("↑"); + expect(fig.EFFORT_LOW).toBe("○"); + }); + + it("FUSION_AX_SCREEN_READER=1 → ASCII downgrades", () => { + process.env.FUSION_AX_SCREEN_READER = "1"; + resetSettingsCache(); + const fig = getFigures(); + expect(fig.BLACK_CIRCLE).toBe("*"); + expect(fig.UP_ARROW).toBe("^"); + expect(fig.EFFORT_LOW).toBe("o"); + expect(fig.BRIDGE_FAILED_INDICATOR).toBe("x"); + }); + + it("switching env + cache reset reflects new state (cache invalidation)", () => { + // First: default Unicode. + expect(getFigures().BLACK_CIRCLE).toBe(BLACK_CIRCLE); + // Then activate reduced motion. + process.env.FUSION_AX_SCREEN_READER = "1"; + resetSettingsCache(); + expect(getFigures().BLACK_CIRCLE).toBe("*"); + // Then back off. + delete process.env.FUSION_AX_SCREEN_READER; + resetSettingsCache(); + expect(getFigures().BLACK_CIRCLE).toBe(BLACK_CIRCLE); + }); + + it("ASCII_SET and UNICODE_SET share the same key set (no missing symbol)", () => { + const off = getFigures(); + process.env.FUSION_AX_SCREEN_READER = "1"; + resetSettingsCache(); + const on = getFigures(); + const offKeys = Object.keys(off).sort(); + const onKeys = Object.keys(on).sort(); + expect(onKeys).toEqual(offKeys); + // Every value is a non-empty string (or frozen array for spinner frames). + for (const k of onKeys) { + const v = (on as Record)[k]; + expect(v).toBeDefined(); + } + }); +}); diff --git a/src/__tests__/ink/colorizeScreenReader.test.ts b/src/__tests__/ink/colorizeScreenReader.test.ts new file mode 100644 index 0000000..75b4357 --- /dev/null +++ b/src/__tests__/ink/colorizeScreenReader.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import chalk, { type ColorSupportLevel } from "chalk"; +import { + applyColor, + applyScreenReaderAnsiGate, + applyTextStyles, + colorize, +} from "../../ink/colorize.js"; +import { getInitialSettings } from "../../utils/settings/settings.js"; +import { resetSettingsCache } from "../../utils/settings/settingsCache.js"; + +// item 19 — screen-reader ANSI gate. When active, colorize()/applyTextStyles() +// return raw strings (no ANSI escape sequences), reusing NO_COLOR semantics +// locally to the Ink render path. Default off = byte-identical current behavior. +// +// The test env runs with NO_COLOR=1 → chalk.level===0, so chalk emits raw +// strings regardless of the gate. To exercise the gate deterministically we +// force chalk.level=3 (truecolor) for the "color applied" assertions and +// restore it after. The gate itself is toggled via the explicit +// applyScreenReaderAnsiGate() API (the module-load capture can't be +// retriggered by env alone without a fresh import). + +const ENV_KEYS = ["FUSION_SCREEN_READER", "FUSION_AX_SCREEN_READER"] as const; + +function clearEnv(): void { + for (const k of ENV_KEYS) { + delete process.env[k]; + } +} + +describe("item 19 — screen-reader ANSI gate (colorize)", () => { + let savedLevel: ColorSupportLevel; + + beforeEach(() => { + clearEnv(); + applyScreenReaderAnsiGate(false); + savedLevel = chalk.level; + }); + + afterEach(() => { + clearEnv(); + applyScreenReaderAnsiGate(false); + chalk.level = savedLevel; + }); + + describe("gate OFF (default) — color applied when chalk emits", () => { + it("colorize('x','ansi:red','foreground') wraps with ANSI at chalk.level 3", () => { + chalk.level = 3 as ColorSupportLevel; + // NOTE: bare "red" (no ansi: prefix) is a colorize no-op that falls + // through every prefix branch and returns raw str — pre-existing + // behavior, not gate-related. The colored path is "ansi:red". + const out = colorize("x", "ansi:red", "foreground"); + expect(out.length).toBeGreaterThan("x".length); + expect(out).toContain("x"); + }); + + it("colorize handles hex/ansi256/rgb/ansi: formats at chalk.level 3", () => { + chalk.level = 3 as ColorSupportLevel; + expect(colorize("x", "#ff0000", "foreground").length).toBeGreaterThan(1); + expect( + colorize("x", "ansi256(196)", "foreground").length, + ).toBeGreaterThan(1); + expect( + colorize("x", "rgb(255,0,0)", "foreground").length, + ).toBeGreaterThan(1); + expect(colorize("x", "ansi:red", "foreground").length).toBeGreaterThan(1); + }); + + it("applyTextStyles('x',{bold:true}) wraps with ANSI at chalk.level 3", () => { + chalk.level = 3 as ColorSupportLevel; + const out = applyTextStyles("x", { bold: true }); + expect(out.length).toBeGreaterThan("x".length); + }); + }); + + describe("gate ON — raw strings, zero ANSI (even at chalk.level 3)", () => { + it("colorize returns raw string for every color format", () => { + chalk.level = 3 as ColorSupportLevel; + applyScreenReaderAnsiGate(true); + // Use the colored paths (ansi:/hex/ansi256/rgb); bare "red" is a + // colorize no-op regardless of gate, so it's excluded here. + expect(colorize("x", "ansi:red", "foreground")).toBe("x"); + expect(colorize("x", "#ff0000", "foreground")).toBe("x"); + expect(colorize("x", "ansi256(196)", "foreground")).toBe("x"); + expect(colorize("x", "rgb(255,0,0)", "foreground")).toBe("x"); + }); + + it("applyTextStyles returns raw text for full style set", () => { + chalk.level = 3 as ColorSupportLevel; + applyScreenReaderAnsiGate(true); + const out = applyTextStyles("x", { + bold: true, + italic: true, + underline: true, + color: "red" as never, + inverse: true, + }); + expect(out).toBe("x"); + }); + + it("applyColor inherits the gate (routes through colorize)", () => { + chalk.level = 3 as ColorSupportLevel; + applyScreenReaderAnsiGate(true); + expect(applyColor("x", "ansi:red")).toBe("x"); + expect(applyColor("x", "#ff0000")).toBe("x"); + }); + }); + + describe("early-return not broken by gate", () => { + it("colorize(x, undefined) returns raw in both states", () => { + expect(colorize("x", undefined, "foreground")).toBe("x"); + applyScreenReaderAnsiGate(true); + expect(colorize("x", undefined, "foreground")).toBe("x"); + }); + + it("applyColor(x, undefined) returns raw in both states", () => { + expect(applyColor("x", undefined)).toBe("x"); + applyScreenReaderAnsiGate(true); + expect(applyColor("x", undefined)).toBe("x"); + }); + }); + + describe("env entry-point chain locks the gate source", () => { + beforeEach(() => { + ( + globalThis as { __fusionScreenReaderOverride?: boolean } + ).__fusionScreenReaderOverride = undefined; + }); + afterEach(() => { + ( + globalThis as { __fusionScreenReaderOverride?: boolean } + ).__fusionScreenReaderOverride = undefined; + resetSettingsCache(); + }); + + it("FUSION_AX_SCREEN_READER=1 → getInitialSettings().prefersReducedMotion === true", () => { + process.env.FUSION_AX_SCREEN_READER = "1"; + resetSettingsCache(); + expect(getInitialSettings().prefersReducedMotion).toBe(true); + }); + + it("no env → prefersReducedMotion not forced true", () => { + resetSettingsCache(); + expect(getInitialSettings().prefersReducedMotion ?? false).toBe(false); + }); + }); +}); diff --git a/src/components/ContextVisualization.tsx b/src/components/ContextVisualization.tsx index 8778f00..9ff0745 100644 --- a/src/components/ContextVisualization.tsx +++ b/src/components/ContextVisualization.tsx @@ -6,9 +6,14 @@ import { generateContextSuggestions } from '../utils/contextSuggestions.js'; import { getDisplayPath } from '../utils/file.js'; import { formatTokens } from '../utils/format.js'; import { getSourceDisplayName, type SettingSource } from '../utils/settings/constants.js'; +import { getInitialSettings } from '../utils/settings/settings.js'; import { plural } from '../utils/stringUtils.js'; import { ContextSuggestions } from './ContextSuggestions.js'; const RESERVED_CATEGORY_NAME = 'Autocompact buffer'; +// screen-reader tree branch: read once at module load (same module-scope +// pattern as LogoV2.tsx). /context is transient — re-mounts on each open, so +// toggling FUSION_AX_SCREEN_READER between sessions reflects on next render. +const TREE_BRANCH = getInitialSettings().prefersReducedMotion === true ? '- ' : '└ '; /** * One-liner for the legend header showing what context-collapse has done. @@ -391,33 +396,33 @@ export function ContextVisualization(t0) { return t18; } function _temp27(attachment, i_10) { - return └ {attachment.name}: {formatTokens(attachment.tokens)} tokens; + return {TREE_BRANCH}{attachment.name}: {formatTokens(attachment.tokens)} tokens; } function _temp26(tool_5, i_9) { - return └ {tool_5.name}: calls {formatTokens(tool_5.callTokens)}, results{" "}{formatTokens(tool_5.resultTokens)}; + return {TREE_BRANCH}{tool_5.name}: calls {formatTokens(tool_5.callTokens)}, results{" "}{formatTokens(tool_5.resultTokens)}; } function _temp25(t0) { const [sourceDisplay_0, sourceSkills] = t0; return {sourceDisplay_0}{sourceSkills.map(_temp24)}; } function _temp24(skill, i_8) { - return └ {skill.name}: {formatTokens(skill.tokens)} tokens; + return {TREE_BRANCH}{skill.name}: {formatTokens(skill.tokens)} tokens; } function _temp23(file, i_7) { - return └ {getDisplayPath(file.path)}: {formatTokens(file.tokens)} tokens; + return {TREE_BRANCH}{getDisplayPath(file.path)}: {formatTokens(file.tokens)} tokens; } function _temp22(t0) { const [sourceDisplay, sourceAgents] = t0; return {sourceDisplay}{sourceAgents.map(_temp21)}; } function _temp21(agent, i_6) { - return └ {agent.agentType}: {formatTokens(agent.tokens)} tokens; + return {TREE_BRANCH}{agent.agentType}: {formatTokens(agent.tokens)} tokens; } function _temp20(section, i_5) { - return └ {section.name}: {formatTokens(section.tokens)} tokens; + return {TREE_BRANCH}{section.name}: {formatTokens(section.tokens)} tokens; } function _temp19(tool_4, i_4) { - return └ {tool_4.name}; + return {TREE_BRANCH}{tool_4.name}; } function _temp18(t_4) { return !t_4.isLoaded; @@ -426,19 +431,19 @@ function _temp17(t_5) { return !t_5.isLoaded; } function _temp16(tool_3, i_3) { - return └ {tool_3.name}: {formatTokens(tool_3.tokens)} tokens; + return {TREE_BRANCH}{tool_3.name}: {formatTokens(tool_3.tokens)} tokens; } function _temp15(t_3) { return t_3.isLoaded; } function _temp14(tool_2, i_2) { - return └ {tool_2.name}: {formatTokens(tool_2.tokens)} tokens; + return {TREE_BRANCH}{tool_2.name}: {formatTokens(tool_2.tokens)} tokens; } function _temp13(tool_1, i_1) { - return └ {tool_1.name}: {formatTokens(tool_1.tokens)} tokens; + return {TREE_BRANCH}{tool_1.name}: {formatTokens(tool_1.tokens)} tokens; } function _temp12(tool_0, i_0) { - return └ {tool_0.name}; + return {TREE_BRANCH}{tool_0.name}; } function _temp11(t_1) { return !t_1.isLoaded; @@ -447,7 +452,7 @@ function _temp10(t_2) { return !t_2.isLoaded; } function _temp1(tool, i) { - return └ {tool.name}: {formatTokens(tool.tokens)} tokens; + return {TREE_BRANCH}{tool.name}: {formatTokens(tool.tokens)} tokens; } function _temp0(t) { return t.isLoaded; diff --git a/src/components/EffortIndicator.ts b/src/components/EffortIndicator.ts index caaaedc..c0d1658 100644 --- a/src/components/EffortIndicator.ts +++ b/src/components/EffortIndicator.ts @@ -1,9 +1,4 @@ -import { - EFFORT_HIGH, - EFFORT_LOW, - EFFORT_MAX, - EFFORT_MEDIUM, -} from '../constants/figures.js' +import { getFigures } from '../constants/figures.js' import { type EffortLevel, type EffortValue, @@ -25,18 +20,19 @@ export function getEffortNotificationText( } export function effortLevelToSymbol(level: EffortLevel): string { + const fig = getFigures() switch (level) { case 'low': - return EFFORT_LOW + return fig.EFFORT_LOW case 'medium': - return EFFORT_MEDIUM + return fig.EFFORT_MEDIUM case 'high': - return EFFORT_HIGH + return fig.EFFORT_HIGH case 'max': - return EFFORT_MAX + return fig.EFFORT_MAX default: // Defensive: level can originate from remote config. If an unknown // value slips through, render the high symbol rather than undefined. - return EFFORT_HIGH + return fig.EFFORT_HIGH } } diff --git a/src/components/MarkdownTable.tsx b/src/components/MarkdownTable.tsx index d81d16e..a43949d 100644 --- a/src/components/MarkdownTable.tsx +++ b/src/components/MarkdownTable.tsx @@ -7,6 +7,17 @@ import { wrapAnsi } from '../ink/wrapAnsi.js'; import { Ansi, useTheme } from '../ink.js'; import type { CliHighlight } from '../utils/cliHighlight.js'; import { formatToken, padAligned } from '../utils/markdown.js'; +import { getInitialSettings } from '../utils/settings/settings.js'; + +// screen-reader table borders: read once at module load. Downgrade Unicode +// box-drawing to ASCII (+/-/|) when prefersReducedMotion is active so screen +// readers read plain text. Default off = byte-identical current borders. +const REDUCED_MOTION = getInitialSettings().prefersReducedMotion === true; +const BORDERS = REDUCED_MOTION + ? { top: ['+', '-', '+', '+'], middle: ['+', '-', '+', '+'], bottom: ['+', '-', '+', '+'] } + : { top: ['┌', '─', '┬', '┐'], middle: ['├', '─', '┼', '┤'], bottom: ['└', '─', '┴', '┘'] }; +const VBAR = REDUCED_MOTION ? '|' : '│'; +const HBAR = REDUCED_MOTION ? '-' : '─'; /** Accounts for parent indentation (e.g. message dot prefix) and terminal * resize races. Without enough margin the table overflows its layout box @@ -206,7 +217,7 @@ export function MarkdownTable({ // Build each line of the row as a single string const result: string[] = []; for (let lineIdx = 0; lineIdx < maxLines_0; lineIdx++) { - let line = '│'; + let line = VBAR; for (let colIndex_2 = 0; colIndex_2 < cells.length; colIndex_2++) { const lines_1 = cellLines[colIndex_2]!; const offset = verticalOffsets[colIndex_2]!; @@ -215,7 +226,7 @@ export function MarkdownTable({ const width_0 = columnWidths[colIndex_2]!; // Headers always centered; data uses table alignment const align = isHeader ? 'center' : token.align?.[colIndex_2] ?? 'left'; - line += ' ' + padAligned(lineText, stringWidth(lineText), width_0, align) + ' │'; + line += ' ' + padAligned(lineText, stringWidth(lineText), width_0, align) + ` ${VBAR}`; } result.push(line); } @@ -224,11 +235,7 @@ export function MarkdownTable({ // Render horizontal border as a single string function renderBorderLine(type: 'top' | 'middle' | 'bottom'): string { - const [left, mid, cross, right] = { - top: ['┌', '─', '┬', '┐'], - middle: ['├', '─', '┼', '┤'], - bottom: ['└', '─', '┴', '┘'] - }[type] as [string, string, string, string]; + const [left, mid, cross, right] = BORDERS[type] as [string, string, string, string]; let line_0 = left; columnWidths.forEach((width_1, colIndex_3) => { line_0 += mid.repeat(width_1 + 2); @@ -242,7 +249,7 @@ export function MarkdownTable({ const lines_2: string[] = []; const headers = token.header.map(h => getPlainText(h.tokens)); const separatorWidth = Math.min(terminalWidth - 1, 40); - const separator = '─'.repeat(separatorWidth); + const separator = HBAR.repeat(separatorWidth); // Small indent for wrapped lines (just 2 spaces) const wrapIndent = ' '; token.rows.forEach((row_2, rowIndex) => { diff --git a/src/constants/figures.ts b/src/constants/figures.ts index b0e84fa..572fc88 100644 --- a/src/constants/figures.ts +++ b/src/constants/figures.ts @@ -1,5 +1,15 @@ +import { createRequire } from 'node:module' import { env } from '../utils/env.js' +// NOTE: getInitialSettings is intentionally NOT imported at module top level. +// figures.ts is imported by PermissionMode.ts (PAUSE_ICON), and settings.js +// transitively imports PermissionMode via settings/types.ts. A top-level +// settings import creates a cycle: figures → settings → settings/types → +// PermissionMode → figures (PAUSE_ICON before initialization = TDZ). +// getFigures() resolves settings lazily on first call (render time, after all +// modules are initialized), breaking the cycle. +const lazyRequire = createRequire(import.meta.url) + // The former is better vertically aligned, but isn't usually supported on Windows/Linux export const BLACK_CIRCLE = env.platform === 'darwin' ? '⏺' : '●' export const BULLET_OPERATOR = '∙' @@ -43,3 +53,109 @@ export const BRIDGE_SPINNER_FRAMES = [ ] export const BRIDGE_READY_INDICATOR = '\u00b7\u2714\ufe0e\u00b7' export const BRIDGE_FAILED_INDICATOR = '\u00d7' + +// screen-reader figure set: when prefersReducedMotion is active, return ASCII +// downgrades for the Unicode symbols above so screen readers read plain text. +// Existing `export const` constants are kept byte-identical for consumers that +// have not migrated to getFigures() \u2014 default-off behavior is unchanged. +// Lazy: first call reads settings at render time (well after module load), +// cached keyed on the reducedMotion boolean so it recomputes on change. +export type FigureSet = Readonly<{ + BLACK_CIRCLE: string + BULLET_OPERATOR: string + TEARDROP_ASTERISK: string + UP_ARROW: string + DOWN_ARROW: string + LIGHTNING_BOLT: string + EFFORT_LOW: string + EFFORT_MEDIUM: string + EFFORT_HIGH: string + EFFORT_MAX: string + PLAY_ICON: string + PAUSE_ICON: string + REFRESH_ARROW: string + CHANNEL_ARROW: string + INJECTED_ARROW: string + FORK_GLYPH: string + DIAMOND_OPEN: string + DIAMOND_FILLED: string + REFERENCE_MARK: string + FLAG_ICON: string + BLOCKQUOTE_BAR: string + HEAVY_HORIZONTAL: string + BRIDGE_SPINNER_FRAMES: readonly string[] + BRIDGE_READY_INDICATOR: string + BRIDGE_FAILED_INDICATOR: string +}> + +const UNICODE_SET: FigureSet = Object.freeze({ + BLACK_CIRCLE, + BULLET_OPERATOR, + TEARDROP_ASTERISK, + UP_ARROW, + DOWN_ARROW, + LIGHTNING_BOLT, + EFFORT_LOW, + EFFORT_MEDIUM, + EFFORT_HIGH, + EFFORT_MAX, + PLAY_ICON, + PAUSE_ICON, + REFRESH_ARROW, + CHANNEL_ARROW, + INJECTED_ARROW, + FORK_GLYPH, + DIAMOND_OPEN, + DIAMOND_FILLED, + REFERENCE_MARK, + FLAG_ICON, + BLOCKQUOTE_BAR, + HEAVY_HORIZONTAL, + BRIDGE_SPINNER_FRAMES, + BRIDGE_READY_INDICATOR, + BRIDGE_FAILED_INDICATOR, +}) + +const ASCII_SET: FigureSet = Object.freeze({ + BLACK_CIRCLE: '*', + BULLET_OPERATOR: '-', + TEARDROP_ASTERISK: '*', + UP_ARROW: '^', + DOWN_ARROW: 'v', + LIGHTNING_BOLT: '>', + EFFORT_LOW: 'o', + EFFORT_MEDIUM: 'o', + EFFORT_HIGH: '*', + EFFORT_MAX: '*', + PLAY_ICON: '>', + PAUSE_ICON: '||', + REFRESH_ARROW: 'R', + CHANNEL_ARROW: '<-', + INJECTED_ARROW: '->', + FORK_GLYPH: 'F', + DIAMOND_OPEN: 'o', + DIAMOND_FILLED: '*', + REFERENCE_MARK: '*', + FLAG_ICON: '!', + BLOCKQUOTE_BAR: '|', + HEAVY_HORIZONTAL: '-', + BRIDGE_SPINNER_FRAMES: Object.freeze(['-|-', '-/-', '---', '-\\-']), + BRIDGE_READY_INDICATOR: '-OK-', + BRIDGE_FAILED_INDICATOR: 'x', +}) + +let cachedFigures: FigureSet | null = null +let cachedFiguresReducedMotion: boolean | null = null + +export function getFigures(): FigureSet { + // Lazy settings resolution \u2014 breaks the figures\u2194settings\u2194PermissionMode + // cycle (see top-of-file NOTE). Render-time only, after module init. + const { getInitialSettings } = lazyRequire('../utils/settings/settings.js') + const reducedMotion = getInitialSettings().prefersReducedMotion === true + if (cachedFigures && cachedFiguresReducedMotion === reducedMotion) { + return cachedFigures + } + cachedFigures = reducedMotion ? ASCII_SET : UNICODE_SET + cachedFiguresReducedMotion = reducedMotion + return cachedFigures +} diff --git a/src/ink/colorize.ts b/src/ink/colorize.ts index 8ddc4e5..65cf32e 100644 --- a/src/ink/colorize.ts +++ b/src/ink/colorize.ts @@ -1,4 +1,5 @@ import chalk from 'chalk' +import { getInitialSettings } from '../utils/settings/settings.js' import type { Color, TextStyles } from './styles.js' /** @@ -61,6 +62,17 @@ function clampChalkLevelForTmux(): boolean { export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs() export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux() +// screen-reader ANSI gate: when active, colorize()/applyTextStyles() return +// raw strings (no ANSI escapes), reusing NO_COLOR semantics locally to the Ink +// render path. In-function gate (not global chalk.level=0) to avoid touching +// non-Ink chalk imports (telemetry/gracefulShutdown/markdown.ts). Default off +// = byte-identical current behavior. +let screenReaderAnsiGateActive = + getInitialSettings().prefersReducedMotion === true +export function applyScreenReaderAnsiGate(active: boolean): void { + screenReaderAnsiGateActive = active +} + export type ColorType = 'foreground' | 'background' const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/ @@ -75,6 +87,10 @@ export const colorize = ( return str } + if (screenReaderAnsiGateActive) { + return str + } + if (color.startsWith('ansi:')) { const value = color.substring('ansi:'.length) switch (value) { @@ -176,6 +192,10 @@ export const colorize = ( export function applyTextStyles(text: string, styles: TextStyles): string { let result = text + if (screenReaderAnsiGateActive) { + return text + } + // Apply styles in reverse order of desired nesting. // chalk wraps text so later calls become outer wrappers. // Desired order (outermost to innermost):