From 8091154785c8aa3d5c42fc070e99189ad408f441 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 21:16:51 +0800 Subject: [PATCH] fix(runtime): sanitize ANSI in screen-reader text, matching Ink (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ink squashes every ink-text via squashTextNodes, which always returns sanitizeAnsi(text) (squash-text-nodes.ts:45) — stripping cursor/erase CSI while keeping SGR + OSC. vue's SR squash concatenated raw text-leaf values with no sanitize, so an embedded control sequence (e.g. \x1b[2J) leaked into screen-reader output. Wrap the squashed SR text in sanitizeAnsi at squashTextContent and the standalone branch — the SR twin of text-measure.ts:54. The double pass on nested/transform text is idempotent (sanitizeAnsi is a fixed point), matching Ink's recursive squashTextNodes. Tests assert erase stripping in Text/Box/Transform AND that SGR is KEPT (so a strip-everything regression is caught). Co-authored-by: Claude Opus 4.8 (1M context) --- .../accessibility/screen-reader.test.tsx | 56 +++++++++++++++++++ packages/runtime/src/paint/screen-reader.ts | 14 ++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx index 65251ad..2777e52 100644 --- a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx +++ b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx @@ -522,6 +522,62 @@ describe("Transform accessibility", () => { }); }); +describe("screen-reader ANSI sanitization (Ink parity)", () => { + // Ink squashes every ink-text via squashTextNodes, which ALWAYS returns + // sanitizeAnsi(text) (squash-text-nodes.ts:45) — stripping cursor/erase CSI + // (e.g. `\x1b[2J`) while keeping SGR + OSC. vue-tui's SR squash previously + // concatenated raw text-leaf values with NO sanitize, so an embedded control + // sequence survived into screen-reader output. This is the SR twin of + // text-measure.ts:54 (`return sanitizeAnsi(out)`). + // eslint-disable-next-line no-control-regex -- ESC erase code is a control char by definition; testing it is the point + const ERASE_SCREEN = "\x1b[2J"; + + test("strips an erase CSI embedded in in screen-reader mode", () => { + const output = renderToString( + defineComponent(() => () => {`a${ERASE_SCREEN}b`}), + { isScreenReaderEnabled: true }, + ); + // The erase sequence is stripped; visible chars survive. + expect(output).toBe("ab"); + }); + + test("strips an erase CSI in nested under in screen-reader mode", () => { + const output = renderToString( + defineComponent(() => () => ( + + {`x${ERASE_SCREEN}y`} + + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("xy"); + }); + + test("strips an erase CSI inside a standalone in screen-reader mode", () => { + const output = renderToString( + defineComponent(() => () => ( + s}>{`p${ERASE_SCREEN}q`} + )), + { isScreenReaderEnabled: true }, + ); + expect(output).toBe("pq"); + }); + + test("keeps SGR (color) sequences in screen-reader output, matching Ink's sanitizeAnsi", () => { + // sanitizeAnsi strips cursor/erase CSI but KEEPS SGR + OSC — so this would + // FAIL against a strip-everything replacement, proving we mirror Ink's + // sanitizeAnsi (sanitize-ansi.ts), not a blanket ANSI strip. + // eslint-disable-next-line no-control-regex -- SGR codes are control chars; asserting they survive is the point + const colored = "a\x1b[31mb\x1b[39mc"; + const output = renderToString( + defineComponent(() => () => {`${colored}${ERASE_SCREEN}`}), + { isScreenReaderEnabled: true }, + ); + // SGR kept, trailing erase stripped. + expect(output).toBe(colored); + }); +}); + describe("integration: aria props via render", () => { test("no unknown prop warnings for aria props", async () => { // This test verifies that aria props don't trigger the "[vue-tui] unknown prop" warning diff --git a/packages/runtime/src/paint/screen-reader.ts b/packages/runtime/src/paint/screen-reader.ts index 4248942..3223bde 100644 --- a/packages/runtime/src/paint/screen-reader.ts +++ b/packages/runtime/src/paint/screen-reader.ts @@ -1,5 +1,6 @@ import Yoga from "yoga-layout"; import type { TuiNode, TuiText, TuiVirtualText, TuiBox } from "../host/nodes.ts"; +import { sanitizeAnsi } from "./sanitize-ansi.ts"; /** * Squash a single child of a text/transform context into plain SR text, @@ -61,7 +62,12 @@ function squashTextContent(node: TuiText | TuiVirtualText): string { // React's absent childNodes, must NOT advance the transform index. if (child.type !== "comment") index++; } - return text; + // Strip cursor/erase control sequences (keep SGR/OSC) from the squashed text, + // exactly as Ink's squashTextNodes returns sanitizeAnsi(text) + // (squash-text-nodes.ts:45). This is the SR twin of host/text-measure.ts:54. + // Without it an embedded control sequence (e.g. `\x1b[2J`) survives into + // screen-reader output. + return sanitizeAnsi(text); } /** @@ -192,7 +198,11 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt squashed += squashChildSR(childNode, index); if (childNode.type !== "comment") index++; } - output = squashed; + // A standalone is an `ink-text` node in Ink, squashed via + // squashTextNodes which returns sanitizeAnsi(text) (squash-text-nodes.ts:45). + // Strip cursor/erase control sequences (keep SGR/OSC) so an embedded control + // sequence does not survive into screen-reader output. + output = sanitizeAnsi(squashed); } // Add accessibility annotations