diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index 2960734..d5c0dac 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -30,24 +30,25 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor `status` ∈ `todo · in-progress · pr-open · merged · blocked · refuted`. Priority: correctness/behavior first, omissions next. -| id | area | summary | priority | status | branch | PR | -| --- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ------- | ---------------------------------- | --- | -| G01 | static-newline-spacer | Static keeps every already-written item permanently mounted instead of unmounting it | P1 | merged | `fix/parity-static-unmount` | #34 | -| G02 | app-exit-instances-animation-sr | useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' | P1 | pr-open | `fix/parity-useanimation-throttle` | #35 | -| G03 | render-lifecycle-reconciler | Live screen-reader render path is missing; commit() always paints the visual grid | P1 | todo | — | — | -| G04 | box-layout-border | Border edges incorrectly inherit the Box backgroundColor | P2 | merged | `fix/parity-border-bg` | #30 | -| G05 | box-layout-border | Borders skipped when content area is 1 cell tall or wide (w<2 / h<2 guard) | P2 | todo | — | — | -| G06 | text-wrap-transform | Nested / transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — | -| G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | todo | — | — | -| G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 | -| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | todo | — | — | -| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | todo | — | — | -| G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | todo | — | — | -| G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | merged | `fix/parity-renderer-size` | #33 | -| G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | todo | — | — | -| G14 | app-exit-instances-animation-sr | No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout | P3 | todo | — | — | -| G15 | box-layout-border | Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned | P2 | todo | — | — | -| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | todo | — | — | +| id | area | summary | priority | status | branch | PR | +| --- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | ---------------------------------- | --- | +| G01 | static-newline-spacer | Static keeps every already-written item permanently mounted instead of unmounting it | P1 | merged | `fix/parity-static-unmount` | #34 | +| G02 | app-exit-instances-animation-sr | useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' | P1 | merged | `fix/parity-useanimation-throttle` | #35 | +| G03 | render-lifecycle-reconciler | Live screen-reader render path is missing; commit() always paints the visual grid | P1 | pr-open | `fix/parity-sr-render` | #36 | +| G04 | box-layout-border | Border edges incorrectly inherit the Box backgroundColor | P2 | merged | `fix/parity-border-bg` | #30 | +| G05 | box-layout-border | Borders skipped when content area is 1 cell tall or wide (w<2 / h<2 guard) | P2 | todo | — | — | +| G06 | text-wrap-transform | Nested / transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — | +| G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | todo | — | — | +| G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 | +| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | todo | — | — | +| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | todo | — | — | +| G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | todo | — | — | +| G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | merged | `fix/parity-renderer-size` | #33 | +| G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | todo | — | — | +| G14 | app-exit-instances-animation-sr | No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout | P3 | todo | — | — | +| G15 | box-layout-border | Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned | P2 | todo | — | — | +| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | todo | — | — | +| 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 | — | — | ## Gap details @@ -304,3 +305,12 @@ _Surfaced by codex during the G04 review (2026-05-29), not the sweep-1 audit._ - **Ink:** `/tmp/ink-40b3a75/src/render-border.ts:54` — `borderTopDimColor ?? borderDimColor`, so an explicit per-edge `false` overrides the general `borderDimColor`. - **vue-tui:** `packages/runtime/src/paint/paint.ts:351` — `(props[\`border${capEdge}DimColor\`] as boolean | undefined) || dimAll`, so a per-edge `false`cannot turn dim off once`borderDimColor` is set. - **Fix sketch:** use a nullish fallback `?? dimAll` (treating only `undefined` as "inherit"), matching Ink. + +### G17 — Screen-reader live-path edges (deferred from G03) + +_area:_ `render-lifecycle-reconciler` · _kind:_ behavior · _severity:_ low · _priority:_ P3 + +_Surfaced by codex during the G03 review (2026-05-30). G03 fixed the core live SR render (dynamic frame now linear, not the 2D grid); these two SR-mode edges remain:_ + +- **SR `` still grid-painted:** the SR dynamic frame uses `skipStaticElements:true`, but `commit()` still flushes `` via `paintStaticNode`→`paintIsolated` (the 2D grid painter), so bordered static content can still emit box glyphs in SR mode. Ink linearizes SR static too (`/tmp/ink-40b3a75/src/renderer.ts:24` uses `skipStaticElements:false` for the static pass). Fix: linearize the SR static channel (renderScreenReaderOutput over the fresh static children) when `isScreenReaderEnabled`. +- **Empty SR frame trailing newline:** interactive SR frames go through the normal frame writer which appends `"\n"` for non-fullscreen/empty frames (`render.ts` write path), so an empty SR frame renders a blank line. Ink writes the `wrappedOutput` directly with height from that string and no added newline (`/tmp/ink-40b3a75/src/ink.tsx:600`). Fix: in SR mode, don't append the trailing newline for empty output. diff --git a/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx new file mode 100644 index 0000000..a12149d --- /dev/null +++ b/packages/runtime-tests/integration/accessibility/screen-reader-live.test.tsx @@ -0,0 +1,86 @@ +import { defineComponent, nextTick } from "vue"; +import { expect, test } from "vite-plus/test"; +import { Box, createApp, Text } from "@vue-tui/runtime"; +import { + makeFakeStdin, + makeFakeWritable, + captureWrites, + getContentWrites, +} from "../lifecycle/test-streams.ts"; + +// G03 (Ink parity): the LIVE commit path must branch on isScreenReaderEnabled +// and emit the flat, linearized screen-reader text (via renderScreenReaderOutput), +// NOT the 2D grid produced by paint() — which would include border glyphs. + +test.sequential("live commit path emits linear screen-reader text (no border glyphs) when SR enabled", async () => { + const App = defineComponent(() => { + return () => ( + + Hello world + + ); + }); + + 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 text content must be present. + expect(content).toContain("Hello world"); + + // Border / box-drawing glyphs must NOT appear — SR mode linearizes the tree. + const borderGlyphs = ["╭", "╮", "╰", "╯", "─", "│"]; + for (const glyph of borderGlyphs) { + expect(content).not.toContain(glyph); + } + + app.unmount(); +}); + +test.sequential("live commit path WITHOUT SR still emits 2D grid with border glyphs (contrast)", async () => { + const App = defineComponent(() => { + return () => ( + + Hello world + + ); + }); + + 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, + }); + + await nextTick(); + await nextTick(); + + const content = getContentWrites(writes).join(""); + expect(content).toContain("Hello world"); + // Non-SR path renders the visual frame, which DOES contain border glyphs. + expect(content).toContain("─"); + + app.unmount(); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 4d39972..62c9150 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -14,6 +14,7 @@ import { EventEmitter } from "node:events"; import isInCi from "is-in-ci"; import patchConsoleFn from "patch-console"; import ansiEscapes from "ansi-escapes"; +import wrapAnsi from "wrap-ansi"; import { createInputParser, type InputEvent } from "./io/input-parser.ts"; import { createKittyKeyboardController, type KittyKeyboardOptions } from "./io/kitty-keyboard.ts"; import { createRoot, emitLayoutListeners, type TuiRoot, type TuiNode } from "./host/nodes.ts"; @@ -22,6 +23,7 @@ import { buildNodeOps } from "./host/node-ops.ts"; import { createCommitScheduler } from "./scheduler.ts"; import { createAnimationScheduler } from "./animation-scheduler.ts"; import { paint } from "./paint/paint.ts"; +import { renderScreenReaderOutput } from "./paint/screen-reader.ts"; import { findStatics, paintStaticNode } from "./paint/static-channel.ts"; import { createFrameWriter } from "./io/frame-writer.ts"; import { bsu, esu, shouldSynchronize } from "./io/write-synchronized.ts"; @@ -537,6 +539,20 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp frameState.outputHeight = outputHeight; } + // Produce the dynamic frame for a given terminal width. In screen-reader + // mode the tree is linearized to flat plain text (no borders / 2D grid) + // via renderScreenReaderOutput, then wrapped with wrapAnsi(trim:false, + // hard:true) — matching Ink's onRender SR branch (ink.tsx:598-603). The + // channel is excluded here (skipStaticElements) just like + // render-to-string.ts; static output is handled separately by commit(). + function renderFrame(width: number): string { + if (!isScreenReaderEnabled) { + return paint(tuiRoot); + } + const linear = renderScreenReaderOutput(tuiRoot, { skipStaticElements: true }); + return wrapAnsi(linear, width, { trim: false, hard: true }); + } + function commit() { const start = onRender ? performance.now() : 0; @@ -574,7 +590,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp tuiRoot.yoga.setWidth(w); tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR); emitLayoutListeners(tuiRoot); - const frame = paint(tuiRoot); + const frame = renderFrame(w); frameState.lastOutput = frame; frameState.lastOutputToRender = frame + "\n"; frameState.outputHeight = frame === "" ? 0 : frame.split("\n").length; @@ -585,7 +601,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp tuiRoot.yoga.setWidth(w); tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR); emitLayoutListeners(tuiRoot); - const frame = paint(tuiRoot); + const frame = renderFrame(w); const outputHeight = frame === "" ? 0 : frame.split("\n").length; if (debug) {