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:
@@ -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,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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user