From 6469b08c46005f17295534afc23c76671af0e88e Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 15 Jun 2026 03:04:54 +0800 Subject: [PATCH] fix(runtime): skip eager visual validation under screen-reader mode (Ink parity) (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertBoxValid (Box) and text.vue's validate() run eager render-time validation of paint-time VISUAL props (backgroundColor, border fg/bg colors, borderStyle shape) and throw into the error boundary on an invalid value (e.g. a chalk modifier name like "bold" used as a color). They were gated only by the per-node ariaHidden skip (srHidden), not by GLOBAL screen-reader mode. Under global SR mode (isScreenReaderEnabled; INK_SCREEN_READER=true) vue-tui, like Ink, linearizes the whole tree to PLAIN TEXT and never colorizes / draws borders for any node — Ink's colorize path is bypassed entirely, so it never throws on an invalid color. vue-tui still ran the eager validation for non- ariaHidden boxes under SR and threw, crashing a screen-reader user out of accessible content over a paint-only prop value. Skip the eager visual validation when global SR is on, in addition to the existing per-node srHidden skip: box.vue gates `!srHidden && (srEnabled || assertBoxValid(props))`, text.vue gates `!srHidden && (srEnabled || validate()) && hasContent`. The validation is all paint-time visual input (no structural checks), so skipping it under SR is safe and matches Ink. Verified against real Ink v7.0.4: with INK_SCREEN_READER=true a renders plain text and does NOT throw; without it Ink throws in colorize.js. This is an alignment fix (removes a vue-tui over-throw), not a new divergence — the existing ink-divergences entry gets a factual, unstamped note about the SR carve-out. Co-authored-by: Claude Opus 4.8 (1M context) --- .agents/docs/ink-divergences.md | 13 +++ .../components/background-color.test.tsx | 79 +++++++++++++++++++ .../runtime/src/components/box-validate.ts | 13 ++- packages/runtime/src/components/box.vue | 24 ++++-- packages/runtime/src/components/text.vue | 9 ++- 5 files changed, 128 insertions(+), 10 deletions(-) diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index c529f62..c012aea 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -748,6 +748,19 @@ different runtime behavior, ownership rule, or out-of-contract handling. content, and content-gated validation is a latent footgun. Principle: reasonable behavior over incidental Ink parity. The former `wouldRenderNonEmptyText` gate was removed. Screen-reader-hidden Text still returns before validation (matches Box). [VOUCHED @hyf0] +- **Global screen-reader mode is carved out (skipped) — ALIGNS to Ink, not a new + divergence:** all of the above validation is paint-time VISUAL input (color / bg / + border), and under GLOBAL screen-reader mode (`isScreenReaderEnabled`; + `INK_SCREEN_READER=true`) vue-tui — like Ink — linearizes the whole tree to PLAIN TEXT + and never colorizes / draws borders for any node. Ink's colorize path is bypassed + entirely under SR, so it never throws on an invalid color (run-verified against Ink + v7.0.4: `` with `INK_SCREEN_READER=true` renders plain text + and does NOT throw; without it Ink throws in `colorize.js`). vue-tui previously still ran + the eager validation for non-`ariaHidden` boxes under SR and threw — crashing a + screen-reader user out of accessible content over a paint-only prop value. The validation + is now skipped when global SR is on (`box.vue` / `text.vue` v-if gate on `srEnabled`), + matching Ink. This removes a vue-tui over-throw and so is an alignment fix, not a new + divergence. Tests: the "GLOBAL SR" cases in `background-color.test.tsx`. ## Non-Behavioral Notes diff --git a/packages/runtime-tests/integration/components/background-color.test.tsx b/packages/runtime-tests/integration/components/background-color.test.tsx index deacd43..9d0811b 100644 --- a/packages/runtime-tests/integration/components/background-color.test.tsx +++ b/packages/runtime-tests/integration/components/background-color.test.tsx @@ -1101,3 +1101,82 @@ test("screen-reader-hidden Text with modifier-name backgroundColor does NOT thro )); expect(() => renderToString(App, { columns: 100, isScreenReaderEnabled: true })).not.toThrow(); }); + +// GLOBAL screen-reader mode (Ink parity): even a NON-aria-hidden / with +// a modifier-name backgroundColor must NOT throw when global SR mode is on. Under +// SR, vue-tui (like Ink) linearizes the whole tree to PLAIN TEXT — it never +// colorizes and never draws borders for ANY node — so there is nothing to validate +// and the eager visual validation is spurious. A screen-reader user must get the +// accessible plain-text content, not a crash, because of a paint-only prop value. +// Verified against Ink v7.0.4: with INK_SCREEN_READER=true, +// renders plain text and does NOT throw; without it, Ink throws in colorize. +test("GLOBAL SR: non-hidden Box with modifier-name backgroundColor renders text, does NOT throw", ({ + expect, +}) => { + const App = defineComponent(() => () => ( + + accessible content + + )); + let out = ""; + expect(() => { + out = renderToString(App, { columns: 100, isScreenReaderEnabled: true }); + }).not.toThrow(); + // The accessible plain-text content is rendered (not crashed away). + expect(out).toContain("accessible content"); +}); + +test("GLOBAL SR: non-hidden Text with modifier-name backgroundColor renders text, does NOT throw", ({ + expect, +}) => { + const App = defineComponent(() => () => ( + + accessible text + + )); + let out = ""; + expect(() => { + out = renderToString(App, { columns: 100, isScreenReaderEnabled: true }); + }).not.toThrow(); + expect(out).toContain("accessible text"); +}); + +test("GLOBAL SR: non-hidden Box with invalid borderStyle renders text, does NOT throw", ({ + expect, +}) => { + const App = defineComponent(() => () => ( + // Cast through unknown: an unknown borderStyle name is invalid by the public + // type, but a JS caller can pass it — under SR the border is never drawn, so it + // must not throw. (box-validate's borderStyle shape-check is skipped under SR.) + + bordered content + + )); + let out = ""; + expect(() => { + out = renderToString(App, { columns: 100, isScreenReaderEnabled: true }); + }).not.toThrow(); + expect(out).toContain("bordered content"); +}); + +// Regression guard: WITHOUT global SR mode, the same invalid backgroundColor MUST +// still throw at render — the eager visual validation is preserved for the normal +// (painted) path. (Twin of the throw-on-modifier tests above; pins that the SR +// carve-out does not weaken validation off the SR path.) +test("regression: non-SR Box with modifier-name backgroundColor STILL throws", ({ expect }) => { + const App = defineComponent(() => () => ( + + painted content + + )); + expect(() => renderToString(App, { columns: 100 })).toThrow(/backgroundColor/i); +}); + +test("regression: non-SR Text with modifier-name backgroundColor STILL throws", ({ expect }) => { + const App = defineComponent(() => () => ( + + painted text + + )); + expect(() => renderToString(App, { columns: 100 })).toThrow(/backgroundColor/i); +}); diff --git a/packages/runtime/src/components/box-validate.ts b/packages/runtime/src/components/box-validate.ts index c6a0646..ba78894 100644 --- a/packages/runtime/src/components/box-validate.ts +++ b/packages/runtime/src/components/box-validate.ts @@ -34,8 +34,17 @@ function isValidBoxStyleShape(value: unknown): value is BoxStyle { /** * Eager render-time validation for ``. Runs every render and throws into the * error boundary on invalid input — exactly as box.ts's render fn did. Returns - * `true` so it can gate a `v-if`. Callers must skip it for a screen-reader-hidden - * Box (a non-emitted node never colorizes — same ordering as box.ts). + * `true` so it can gate a `v-if`. + * + * Everything validated here is PAINT-TIME VISUAL input (own/border background colors, + * per-edge border foreground colors, borderStyle shape) — it only matters when the + * Box is actually painted. There is no structural validation here. So callers must + * skip it whenever the Box's visuals are never painted: + * - a screen-reader-HIDDEN Box (a non-emitted node never colorizes — same ordering + * as box.ts), and + * - GLOBAL screen-reader mode (the whole tree is linearized to plain text; vue-tui, + * like Ink, never colorizes / draws borders for ANY node, so it never throws on an + * invalid color — verified against Ink v7.0.4). See box.vue's v-if. */ export function assertBoxValid(props: BoxProps): true { // --- backgroundColor validation (A12) --- diff --git a/packages/runtime/src/components/box.vue b/packages/runtime/src/components/box.vue index 2d79526..f2a5c25 100644 --- a/packages/runtime/src/components/box.vue +++ b/packages/runtime/src/components/box.vue @@ -17,13 +17,23 @@ const srHidden = computed(() => srEnabled.value && props.ariaHidden);