fix(runtime): linearize screen-reader static + drop empty-SR-frame newline (Ink parity, G17) (#45)
* fix(runtime): linearize screen-reader static output + drop empty-SR-frame newline (Ink parity, G17) (a) The live static channel flushed <Static> via the 2D grid painter (paintIsolated) even in screen-reader mode, so bordered static items leaked box glyphs. paintStaticNode now takes an isScreenReaderEnabled flag and linearizes fresh static children via renderScreenReaderOutput (skipStaticElements:false) instead — matching Ink's renderer.ts:24, which renders node.staticNode through renderNodeToScreenReaderOutput. Non-SR static is unchanged. render.ts commit() and render-to-string.ts thread the flag. (b) Interactive SR frames went through renderInteractiveFrame, which appends "\n" even for empty output, leaking a spurious blank line. Ink's SR path writes the wrapped output directly with lastOutputToRender = wrappedOutput (no appended newline), so an empty SR frame emits zero lines. We now suppress the trailing newline for EMPTY SR output only — matching ink.tsx:573-626 — leaving non-SR and non-empty SR frames untouched. Follow-up fixes (two review findings): - renderToString in SR mode no longer DROPS <Static> output: the SR return branch now prepends the captured/linearized static output like the non-SR path (Ink's SR renderer returns staticOutput when node.staticNode exists, renderer.ts:24-33). - The SR static linearization now honors the <Static>'s resolved flexDirection for separator + child order (read from yoga via getFlexDirection), matching screen-reader.ts:73-82 (row/row-reverse → space, *-reverse reverses order); the default column case is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(parity): ledger — G17 pr-open, reconcile G16 merged Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { defineComponent, nextTick } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { Box, createApp, Text } from "@vue-tui/runtime";
|
||||
import { Box, createApp, Static, Text } from "@vue-tui/runtime";
|
||||
import {
|
||||
makeFakeStdin,
|
||||
makeFakeWritable,
|
||||
@@ -84,3 +84,99 @@ test.sequential("live commit path WITHOUT SR still emits 2D grid with border gly
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
// G17 edge (a) (Ink parity): the LIVE static channel must ALSO linearize in SR
|
||||
// mode. The dynamic frame already excludes <Static> (skipStaticElements:true),
|
||||
// but commit() flushes statics separately — and previously via the 2D grid
|
||||
// painter (paintIsolated), so a bordered static item leaked box glyphs even in
|
||||
// SR mode. Ink linearizes static too: renderer.ts renders node.staticNode via
|
||||
// renderNodeToScreenReaderOutput({ skipStaticElements:false }).
|
||||
test.sequential("live static channel emits linear screen-reader text (no border glyphs) when SR enabled", async () => {
|
||||
const App = defineComponent(() => {
|
||||
const items = ["Logged in"];
|
||||
return () => (
|
||||
<Box flexDirection="column">
|
||||
<Static items={items}>
|
||||
{{
|
||||
default: ({ item }: { item: string }) => (
|
||||
<Box key={item} borderStyle="round">
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
</Static>
|
||||
<Text>Live</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
const stdout = makeFakeWritable({ columns: 80 });
|
||||
const stderr = makeFakeWritable({ columns: 80 });
|
||||
const { stream: stdin } = makeFakeStdin();
|
||||
const writes = captureWrites(stdout);
|
||||
|
||||
app.mount({
|
||||
stdout,
|
||||
stdin,
|
||||
stderr,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: true,
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const content = getContentWrites(writes).join("");
|
||||
|
||||
// The flat static text content must be present.
|
||||
expect(content).toContain("Logged in");
|
||||
|
||||
// Border / box-drawing glyphs must NOT appear — the static channel must
|
||||
// linearize the bordered Box in SR mode just like the dynamic frame.
|
||||
const borderGlyphs = ["╭", "╮", "╰", "╯", "─", "│"];
|
||||
for (const glyph of borderGlyphs) {
|
||||
expect(content).not.toContain(glyph);
|
||||
}
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
// G17 edge (b) (Ink parity): an EMPTY SR frame must not write a spurious blank
|
||||
// trailing line. Ink's SR path writes the wrapped output directly with
|
||||
// lastOutputToRender = wrappedOutput (NO appended "\n"), and an empty frame is
|
||||
// "" → height 0, so nothing is emitted (ink.tsx:599-621). The normal frame
|
||||
// writer appends "\n" even for empty frames, which would leak a blank line.
|
||||
test.sequential("empty SR frame does not write a spurious blank trailing line", async () => {
|
||||
const App = defineComponent(() => {
|
||||
// A Box with no visible text produces an empty linearized SR frame.
|
||||
return () => <Box />;
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
const stdout = makeFakeWritable({ columns: 80 });
|
||||
const stderr = makeFakeWritable({ columns: 80 });
|
||||
const { stream: stdin } = makeFakeStdin();
|
||||
const writes = captureWrites(stdout);
|
||||
|
||||
app.mount({
|
||||
stdout,
|
||||
stdin,
|
||||
stderr,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: true,
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const content = getContentWrites(writes).join("");
|
||||
|
||||
// An empty SR frame must not produce any newline-only / blank write. The
|
||||
// non-empty case appends a newline via the frame writer; the empty case
|
||||
// must produce zero output lines (Ink: wrappedOutput === "" writes nothing).
|
||||
expect(content).not.toContain("\n");
|
||||
expect(content).toBe("");
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineComponent, type FunctionalComponent } from "vue";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { renderToString, Box, Text, Transform } from "@vue-tui/runtime";
|
||||
import { renderToString, Box, Text, Transform, Static } from "@vue-tui/runtime";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import {
|
||||
createRoot,
|
||||
@@ -591,4 +591,117 @@ describe("screen reader enabled mode", () => {
|
||||
"listbox: (multiselectable) option: (selected) Option 1\noption: Option 2\noption: (selected) Option 3",
|
||||
);
|
||||
});
|
||||
|
||||
// G17 follow-up, finding 2 (Ink parity): SR renderToString must NOT drop
|
||||
// <Static> content. The SR static flush linearizes static items, but the SR
|
||||
// return branch previously returned only the dynamic output (rendered with
|
||||
// skipStaticElements:true), discarding the captured static output. Ink's SR
|
||||
// renderer returns staticOutput when node.staticNode exists (renderer.ts:24-33).
|
||||
test("renderToString in SR mode includes <Static> item text (does not drop it)", () => {
|
||||
const output = renderToString(
|
||||
defineComponent(() => {
|
||||
const items = ["First", "Second"];
|
||||
return () => (
|
||||
<Box flexDirection="column">
|
||||
<Static items={items}>
|
||||
{{
|
||||
default: ({ item }: { item: string }) => (
|
||||
<Box key={item} borderStyle="round">
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
</Static>
|
||||
<Text>Live</Text>
|
||||
</Box>
|
||||
);
|
||||
}),
|
||||
{ isScreenReaderEnabled: true },
|
||||
);
|
||||
// Static content must be present (it was previously discarded).
|
||||
expect(output).toContain("First");
|
||||
expect(output).toContain("Second");
|
||||
// Dynamic content still present.
|
||||
expect(output).toContain("Live");
|
||||
// SR mode linearizes — no border glyphs from the bordered static items.
|
||||
for (const glyph of ["╭", "╮", "╰", "╯", "─", "│"]) {
|
||||
expect(output).not.toContain(glyph);
|
||||
}
|
||||
});
|
||||
|
||||
// G17 follow-up, finding 1 (Ink parity): the SR static linearization must
|
||||
// honor the <Static>'s resolved flexDirection for separator + child order,
|
||||
// matching how screen-reader.ts linearizes a container (row/row-reverse → " ",
|
||||
// *-reverse reverses children). The default column case still joins with "\n".
|
||||
test("renderToString in SR mode honors Static flexDirection=row (space separator)", () => {
|
||||
const output = renderToString(
|
||||
defineComponent(() => {
|
||||
const items = ["Alpha", "Beta"];
|
||||
return () => (
|
||||
<Static items={items} style={{ flexDirection: "row" }}>
|
||||
{{
|
||||
default: ({ item }: { item: string }) => <Text key={item}>{item}</Text>,
|
||||
}}
|
||||
</Static>
|
||||
);
|
||||
}),
|
||||
{ isScreenReaderEnabled: true },
|
||||
);
|
||||
// Row direction uses a space separator (screen-reader.ts:76), not "\n".
|
||||
expect(output).toBe("Alpha Beta");
|
||||
});
|
||||
|
||||
test("renderToString in SR mode honors Static flexDirection=row-reverse (reversed, space)", () => {
|
||||
const output = renderToString(
|
||||
defineComponent(() => {
|
||||
const items = ["Alpha", "Beta"];
|
||||
return () => (
|
||||
<Static items={items} style={{ flexDirection: "row-reverse" }}>
|
||||
{{
|
||||
default: ({ item }: { item: string }) => <Text key={item}>{item}</Text>,
|
||||
}}
|
||||
</Static>
|
||||
);
|
||||
}),
|
||||
{ isScreenReaderEnabled: true },
|
||||
);
|
||||
// row-reverse reverses child order (screen-reader.ts:79-82) + space separator.
|
||||
expect(output).toBe("Beta Alpha");
|
||||
});
|
||||
|
||||
test("renderToString in SR mode honors Static flexDirection=column-reverse (reversed, newline)", () => {
|
||||
const output = renderToString(
|
||||
defineComponent(() => {
|
||||
const items = ["Alpha", "Beta"];
|
||||
return () => (
|
||||
<Static items={items} style={{ flexDirection: "column-reverse" }}>
|
||||
{{
|
||||
default: ({ item }: { item: string }) => <Text key={item}>{item}</Text>,
|
||||
}}
|
||||
</Static>
|
||||
);
|
||||
}),
|
||||
{ isScreenReaderEnabled: true },
|
||||
);
|
||||
// column-reverse reverses order, newline separator (default non-row).
|
||||
expect(output).toBe("Beta\nAlpha");
|
||||
});
|
||||
|
||||
test("renderToString in SR mode default Static (column) joins with newline, forward order", () => {
|
||||
const output = renderToString(
|
||||
defineComponent(() => {
|
||||
const items = ["Alpha", "Beta"];
|
||||
return () => (
|
||||
<Static items={items}>
|
||||
{{
|
||||
default: ({ item }: { item: string }) => <Text key={item}>{item}</Text>,
|
||||
}}
|
||||
</Static>
|
||||
);
|
||||
}),
|
||||
{ isScreenReaderEnabled: true },
|
||||
);
|
||||
// Default column: forward order, newline separator (unchanged behavior).
|
||||
expect(output).toBe("Alpha\nBeta");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user