diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index 9b46b74..d7d4d2f 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -49,8 +49,8 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor | G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | merged | `fix/parity-custom-border` | #42 | | G14 | app-exit-instances-animation-sr | No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout | P3 | merged | `fix/parity-instance-reuse` | #43 | | G15 | box-layout-border | Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned | P2 | merged | `fix/parity-border-1cell` | #37 | -| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | pr-open | `fix/parity-border-dim` | #44 | -| G17 | render-lifecycle-reconciler | Screen-reader live-path edges: still grid-painted (Ink linearizes, skipStaticElements:false) + empty SR frame gets a trailing newline (Ink writes wrapped output directly) | P3 | todo | — | — | +| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | merged | `fix/parity-border-dim` | #44 | +| G17 | render-lifecycle-reconciler | Screen-reader live-path edges: still grid-painted (Ink linearizes, skipStaticElements:false) + empty SR frame gets a trailing newline (Ink writes wrapped output directly) | P3 | pr-open | `fix/parity-sr-edges` | #45 | ## Gap details diff --git a/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx index a12149d..5a61e96 100644 --- a/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx +++ b/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx @@ -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 (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 () => ( + + + {{ + default: ({ item }: { item: string }) => ( + + {item} + + ), + }} + + Live + + ); + }); + + 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 () => ; + }); + + 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(); +}); diff --git a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx index 443d624..55b777a 100644 --- a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx +++ b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx @@ -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 + // 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 item text (does not drop it)", () => { + const output = renderToString( + defineComponent(() => { + const items = ["First", "Second"]; + return () => ( + + + {{ + default: ({ item }: { item: string }) => ( + + {item} + + ), + }} + + Live + + ); + }), + { 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 '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 () => ( + + {{ + default: ({ item }: { item: string }) => {item}, + }} + + ); + }), + { 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 () => ( + + {{ + default: ({ item }: { item: string }) => {item}, + }} + + ); + }), + { 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 () => ( + + {{ + default: ({ item }: { item: string }) => {item}, + }} + + ); + }), + { 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 () => ( + + {{ + default: ({ item }: { item: string }) => {item}, + }} + + ); + }), + { isScreenReaderEnabled: true }, + ); + // Default column: forward order, newline separator (unchanged behavior). + expect(output).toBe("Alpha\nBeta"); + }); }); diff --git a/packages/runtime/src/paint/static-channel.ts b/packages/runtime/src/paint/static-channel.ts index 1d48821..73d53e7 100644 --- a/packages/runtime/src/paint/static-channel.ts +++ b/packages/runtime/src/paint/static-channel.ts @@ -1,5 +1,29 @@ +import Yoga from "yoga-layout"; import type { TuiNode, TuiStatic } from "../host/nodes.ts"; import { paintIsolated } from "./paint.ts"; +import { renderScreenReaderOutput } from "./screen-reader.ts"; + +/** + * Read a static node's resolved flexDirection as the string form + * screen-reader.ts compares against ("row" | "row-reverse" | "column" | + * "column-reverse"). node-ops applies flexDirection to yoga but does NOT mirror + * it into `props` (it's not in STYLE_PROPS), so we read it back from the yoga + * node — which holds the resolved direction including the default of + * column. This keeps separator/order derivation identical to how + * screen-reader.ts (screen-reader.ts:73-82) would linearize a container. + */ +function resolvedFlexDirection(stat: TuiStatic): string { + switch (stat.yoga.getFlexDirection()) { + case Yoga.FLEX_DIRECTION_ROW: + return "row"; + case Yoga.FLEX_DIRECTION_ROW_REVERSE: + return "row-reverse"; + case Yoga.FLEX_DIRECTION_COLUMN_REVERSE: + return "column-reverse"; + default: + return "column"; + } +} export function findStatics(root: TuiNode, out: TuiStatic[] = []): TuiStatic[] { if (root.type === "static") out.push(root); @@ -24,7 +48,11 @@ export function findStatics(root: TuiNode, out: TuiStatic[] = []): TuiStatic[] { * its cursor and unmounts them (the post-commit step mirroring Ink's * `useLayoutEffect(setIndex)`). */ -export function paintStaticNode(stat: TuiStatic, columns: number): string { +export function paintStaticNode( + stat: TuiStatic, + columns: number, + isScreenReaderEnabled = false, +): string { const fresh = stat.children.filter((child) => !stat.writtenNodes.has(child)); // Paint (and record as written) only when there is something fresh — but the // prune and onWritten steps below run on EVERY commit, including the empty @@ -35,7 +63,36 @@ export function paintStaticNode(stat: TuiStatic, columns: number): string { // lowers the cursor so subsequent grows ([A,C]) render and write the new item. let frame = ""; if (fresh.length > 0) { - frame = paintIsolated(fresh, columns, stat); + if (isScreenReaderEnabled) { + // SR mode: linearize the fresh static children to flat plain text instead + // of the 2D grid painter — otherwise bordered static items would emit box + // glyphs in screen-reader output. Ink does the same: its renderer + // linearizes node.staticNode via renderNodeToScreenReaderOutput + // ({ skipStaticElements:false }) (renderer.ts:24). + // + // We can't simply pass the whole static node to renderScreenReaderOutput: + // its children include already-written items, but the write-once model + // requires painting ONLY the fresh (un-written) children. So we replicate + // exactly how screen-reader.ts linearizes a box/root container of these + // children (screen-reader.ts:73-82): the separator and child order derive + // from the container's resolved flexDirection (defaulting to the + // "column" default set in Static.ts). + const flexDirection = resolvedFlexDirection(stat); + // Match screen-reader.ts:76 exactly — row/row-reverse use a space, all + // other directions (incl. the column default) use a newline. + const separator = flexDirection === "row" || flexDirection === "row-reverse" ? " " : "\n"; + // Match screen-reader.ts:79-82 — *-reverse directions reverse child order. + const ordered = + flexDirection === "row-reverse" || flexDirection === "column-reverse" + ? [...fresh].reverse() + : fresh; + frame = ordered + .map((child) => renderScreenReaderOutput(child, { skipStaticElements: false })) + .filter(Boolean) + .join(separator); + } else { + frame = paintIsolated(fresh, columns, stat); + } for (const child of fresh) stat.writtenNodes.add(child); } // Prune entries that are no longer mounted so the set can't grow unbounded diff --git a/packages/runtime/src/render-to-string.ts b/packages/runtime/src/render-to-string.ts index 9283759..ee4ef78 100644 --- a/packages/runtime/src/render-to-string.ts +++ b/packages/runtime/src/render-to-string.ts @@ -78,7 +78,7 @@ export function renderToString(component: Component, options?: RenderToStringOpt root.yoga.calculateLayout(columns, undefined, Yoga.DIRECTION_LTR); // Flush static output from intermediate renders for (const stat of findStatics(root)) { - const staticFrame = paintStaticNode(stat, columns); + const staticFrame = paintStaticNode(stat, columns, isScreenReaderEnabled); if (staticFrame && staticFrame !== "\n") { capturedStaticOutput += staticFrame + "\n"; } @@ -133,14 +133,14 @@ export function renderToString(component: Component, options?: RenderToStringOpt throw uncaughtError instanceof Error ? uncaughtError : new Error(String(uncaughtError)); } - // Screen reader mode returns plain text directly — no static channel. - if (isScreenReaderEnabled) { - return output; - } - // The static channel appends a trailing newline for terminal rendering // (so dynamic output starts on a fresh line). Strip it here so - // renderToString returns clean output. + // renderToString returns clean output. This applies in BOTH modes: SR mode + // linearizes static items into plain text too (paintStaticNode branches on + // isScreenReaderEnabled), and Ink's SR renderer likewise returns the static + // output when node.staticNode exists (renderer.ts:24-33). Prepending the + // captured static output mirrors the non-SR path so SR renderToString does + // not silently drop content. const normalizedStaticOutput = capturedStaticOutput.endsWith("\n") ? capturedStaticOutput.slice(0, -1) : capturedStaticOutput; diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 2d26623..26f259d 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -565,7 +565,12 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // Fullscreen: output fills or exceeds terminal height — no trailing newline. // Only apply when writing to a real TTY — piped output always gets trailing newlines. const isFullscreen = isTty && outputHeight >= viewportRows; - const outputToRender = isFullscreen ? output : output + "\n"; + // SR parity (G17 edge b): Ink's screen-reader path writes the wrapped + // output directly with NO appended newline (ink.tsx:617-621), so an empty + // SR frame emits zero lines instead of a spurious blank line. We scope + // this to EMPTY SR output to avoid touching non-SR or non-empty SR frames. + const isEmptyScreenReaderFrame = isScreenReaderEnabled && output === ""; + const outputToRender = isFullscreen || isEmptyScreenReaderFrame ? output : output + "\n"; const shouldClear = shouldClearTerminalForFrame({ isTty, @@ -638,7 +643,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp const w = resolveSize(stdout).columns; let staticOutput = ""; for (const stat of findStatics(tuiRoot)) { - const staticFrame = paintStaticNode(stat, w); + const staticFrame = paintStaticNode(stat, w, isScreenReaderEnabled); if (staticFrame.length > 0) { staticOutput += staticFrame + "\n"; }