fix(runtime): skip eager visual validation under screen-reader mode (Ink parity) (#197)

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
<Box backgroundColor="bold"> 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) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-15 03:04:54 +08:00
committed by GitHub
parent 90713177bb
commit 6469b08c46
5 changed files with 128 additions and 10 deletions
+13
View File
@@ -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: `<Box backgroundColor="bold">` 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
@@ -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 <Box>/<Text> 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, <Box backgroundColor="bold">
// 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(() => () => (
<Box backgroundColor="bold" alignSelf="flex-start">
<Text>accessible content</Text>
</Box>
));
let out = "<unset>";
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(() => () => (
<Box alignSelf="flex-start">
<Text backgroundColor="bold">accessible text</Text>
</Box>
));
let out = "<unset>";
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.)
<Box borderStyle={"not-a-border" as unknown as "single"} alignSelf="flex-start">
<Text>bordered content</Text>
</Box>
));
let out = "<unset>";
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(() => () => (
<Box backgroundColor="bold" alignSelf="flex-start">
<Text>painted content</Text>
</Box>
));
expect(() => renderToString(App, { columns: 100 })).toThrow(/backgroundColor/i);
});
test("regression: non-SR Text with modifier-name backgroundColor STILL throws", ({ expect }) => {
const App = defineComponent(() => () => (
<Box alignSelf="flex-start">
<Text backgroundColor="bold">painted text</Text>
</Box>
));
expect(() => renderToString(App, { columns: 100 })).toThrow(/backgroundColor/i);
});
@@ -34,8 +34,17 @@ function isValidBoxStyleShape(value: unknown): value is BoxStyle {
/**
* Eager render-time validation for `<Box>`. 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) ---
+17 -7
View File
@@ -17,13 +17,23 @@ const srHidden = computed(() => srEnabled.value && props.ariaHidden);
<template>
<!-- assertBoxValid runs every render and throws into the error boundary, exactly
as the former render fn did; `!srHidden &&` short-circuits validation when the
Box is screen-reader-hidden (mirrors box-validate.ts ordering: a non-emitted node
never colorizes). Under a screen reader with an ariaLabel, render the label text
instead of the slot. The root `v-if` makes this component a Vue Fragment, so its
`$el` is the fragment's boundary anchor NOT the `tui-box` host node; a Box ref is
resolved to its host node by drilling the component subTree (see useBoxMetrics). -->
<tui-box v-if="!srHidden && assertBoxValid(props)" v-bind="props">
as the former render fn did. It is skipped in two cases, both because the Box's
visual props (bg/border colors, border shape) are then never painted, so there
is nothing to validate and a throw would be spurious:
- `!srHidden`: a screen-reader-hidden Box emits no node (mirrors box-validate.ts
ordering: a non-emitted node never colorizes).
- `srEnabled ||`: under GLOBAL screen-reader mode vue-tui (like Ink) linearizes
the whole tree to PLAIN TEXT — it never colorizes / never draws borders for ANY
node. Ink's render-node-to-output (the colorize path) is bypassed entirely under
SR, so it never throws on an invalid color; verified against Ink v7.0.4 (with
INK_SCREEN_READER=true a modifier-name backgroundColor renders plain text and
does NOT throw; without it Ink throws in colorize). So we short-circuit to `true`
under SR and render the box's accessible content instead of crashing.
Under a screen reader with an ariaLabel, render the label text instead of the slot.
The root `v-if` makes this component a Vue Fragment, so its `$el` is the fragment's
boundary anchor NOT the `tui-box` host node; a Box ref is resolved to its host node
by drilling the component subTree (see useBoxMetrics). -->
<tui-box v-if="!srHidden && (srEnabled || assertBoxValid(props))" v-bind="props">
<tui-text v-if="srEnabled && props.ariaLabel">{{ props.ariaLabel }}</tui-text>
<slot v-else />
</tui-box>
+8 -1
View File
@@ -31,6 +31,13 @@ const hasContent = computed(() => srLabel.value != null || slots.default != null
// a chalk-modifier-name backgroundColor, or a foreground key chalk has but can't
// call like "level" — is caught by vue-tui's error boundary, not the post-flush
// paint pass where a throw wedges the scheduler. Returns true for the v-if.
//
// Skipped under GLOBAL screen-reader mode (`srEnabled ||` in the v-if below): under
// SR vue-tui (like Ink) linearizes the tree to PLAIN TEXT and never colorizes any
// node, so these color props are never painted — validating them would throw
// spuriously and crash a screen-reader user out of accessible content. Verified
// against Ink v7.0.4 (INK_SCREEN_READER=true → modifier-name bg renders plain text,
// does NOT throw; without it Ink throws in colorize). Mirrors box.vue.
function validate(): true {
assertValidForegroundColor(props.color);
assertValidBackgroundColor(props.backgroundColor);
@@ -39,7 +46,7 @@ function validate(): true {
</script>
<template>
<template v-if="!srHidden && validate() && hasContent">
<template v-if="!srHidden && (srEnabled || validate()) && hasContent">
<tui-virtual-text v-if="insideText" v-bind="props">
<template v-if="srLabel">{{ srLabel }}</template>
<slot v-else />