From 0d847f0e48ab00e800387aff99f78ae189c5b9b3 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 30 May 2026 07:10:17 +0800 Subject: [PATCH] fix(runtime): dedup screen-reader role against immediate parent only (Ink parity, G22) (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): dedup screen-reader role against immediate parent only (Ink parity, G22) Drop the `?? options.parentRole` grandparent fallback so a role-less intermediate resets inherited parentRole to undefined for its children, matching Ink's immediate-parent-only role dedup (render-node-to-output.ts:68-69 passes only `node.internal_accessibility?.role`, no fallback). Co-Authored-By: Claude Opus 4.8 * chore(parity): ledger — G22 pr-open, reconcile G21 merged Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .agents/docs/parity-ledger.md | 4 +- .../accessibility/screen-reader.test.tsx | 94 +++++++++++++++++++ packages/runtime/src/paint/screen-reader.ts | 7 +- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index cc83bd2..96640dd 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -55,8 +55,8 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor | G18 | render-lifecycle-reconciler | No signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP (Ink signal-exit at mount) | P1 | merged | `fix/parity-signal-teardown` | #47 | | G19 | box-layout-border | Dynamic removal of most yoga style props does not reset to default (stale layout) | P2 | merged | `fix/parity-yoga-reset` | #48 | | G20 | stdout-stderr-stdin-size-cursor | writeToStdout/writeToStderr lack an isUnmounted/teardown guard (post-teardown writes corrupt terminal) | P2 | merged | `fix/parity-write-after-unmount` | #49 | -| G21 | text-wrap-transform | Nested in gets hardcoded index 0 vs child sibling position (squash path) | P3 | pr-open | `fix/parity-transform-index` | #50 | -| G22 | app-exit-instances-animation-sr | SR role dedup inherits grandparent role; Ink dedups only vs immediate parent | P3 | todo | — | — | +| G21 | text-wrap-transform | Nested in gets hardcoded index 0 vs child sibling position (squash path) | P3 | merged | `fix/parity-transform-index` | #50 | +| G22 | app-exit-instances-animation-sr | SR role dedup inherits grandparent role; Ink dedups only vs immediate parent | P3 | pr-open | `fix/parity-sr-role-dedup` | #51 | | G23 | app-exit-instances-animation-sr | under SR-joins children with newline; Ink concatenates | P3 | todo | — | — | | G24 | render-lifecycle-reconciler | Renders ALL nodes; Ink renders only the most-recent staticNode (multi-Static support — candidate) | P3 | candidate | — (see ink-parity.md) | — | diff --git a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx index 1feeb5a..6c3c104 100644 --- a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx +++ b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx @@ -223,6 +223,100 @@ describe("renderScreenReaderOutput (unit)", () => { root.yoga.freeRecursive(); }); + + // G22 (Ink parity): dedup is only against the IMMEDIATE parent's role. + // A role-less intermediate box must reset the inherited parentRole to undefined + // so a grandchild with the same role as the grandparent IS still announced. + // Ink passes `node.internal_accessibility?.role` (no ?? fallback) to children. + test("G22: grandchild with same role as grandparent IS announced when immediate parent has no role", () => { + // Structure: grandparent[role=list] → middle(no role) → grandchild[role=list] + // Expected: both "list" annotations appear (grandchild is NOT wrongly deduped + // against the grandparent through the role-less intermediate). + const root = createRoot(createTestAppContext()); + attachYoga(root); + root.yoga.setWidth(80); + + const grandparent = createBox(); + attachYoga(grandparent); + grandparent.internal_accessibility = { role: "list" }; + grandparent.parent = root; + root.children.push(grandparent); + root.yoga.insertChild(grandparent.yoga, 0); + + // Role-less intermediate box — must reset parentRole to undefined for its children. + const middle = createBox(); + attachYoga(middle); + // No internal_accessibility on middle (no role). + middle.parent = grandparent; + grandparent.children.push(middle); + grandparent.yoga.insertChild(middle.yoga, 0); + + const grandchild = createBox(); + attachYoga(grandchild); + grandchild.internal_accessibility = { role: "list" }; + grandchild.parent = middle; + middle.children.push(grandchild); + middle.yoga.insertChild(grandchild.yoga, 0); + + const text = createText(); + attachYoga(text); + const leaf = createTextLeaf("Item"); + leaf.parent = text; + text.children.push(leaf); + text.parent = grandchild; + grandchild.children.push(text); + grandchild.yoga.insertChild(text.yoga, 0); + + root.yoga.calculateLayout(80, undefined, DIRECTION_LTR); + + const output = renderScreenReaderOutput(root); + // grandchild shares role with grandparent but NOT with its immediate parent + // (which has no role). It must NOT be deduped — both annotations must appear. + expect(output).toBe("list: list: Item"); + + root.yoga.freeRecursive(); + }); + + // G22 control: immediate-parent dedup is still in effect. + // A child whose DIRECT parent has the same role must still be suppressed. + test("G22 control: child role matching immediate parent is still deduped", () => { + // Structure: parent[role=nav] → child[role=nav] + // Expected: only one "nav:" annotation (child is deduped against immediate parent). + const root = createRoot(createTestAppContext()); + attachYoga(root); + root.yoga.setWidth(80); + + const parent = createBox(); + attachYoga(parent); + parent.internal_accessibility = { role: "nav" }; + parent.parent = root; + root.children.push(parent); + root.yoga.insertChild(parent.yoga, 0); + + const child = createBox(); + attachYoga(child); + child.internal_accessibility = { role: "nav" }; + child.parent = parent; + parent.children.push(child); + parent.yoga.insertChild(child.yoga, 0); + + const text = createText(); + attachYoga(text); + const leaf = createTextLeaf("Link"); + leaf.parent = text; + text.children.push(leaf); + text.parent = child; + child.children.push(text); + child.yoga.insertChild(text.yoga, 0); + + root.yoga.calculateLayout(80, undefined, DIRECTION_LTR); + + const output = renderScreenReaderOutput(root); + // Child shares role with immediate parent — deduped to single annotation. + expect(output).toBe("nav: Link"); + + root.yoga.freeRecursive(); + }); }); describe("Box aria props", () => { diff --git a/packages/runtime/src/paint/screen-reader.ts b/packages/runtime/src/paint/screen-reader.ts index bbe7ff0..7349d85 100644 --- a/packages/runtime/src/paint/screen-reader.ts +++ b/packages/runtime/src/paint/screen-reader.ts @@ -86,12 +86,17 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt : node.children; const boxNode = node as TuiBox; + // Ink parity (G22): pass only the CURRENT node's own role to children — + // no `?? options.parentRole` fallback. When this box has no role, `undefined` + // is forwarded, resetting the inherited parentRole so a grandchild with the + // same role as its grandparent is NOT wrongly deduped (dedup is immediate- + // parent-only, matching Ink render-node-to-output.ts:68-69). const parentRole = boxNode.internal_accessibility?.role; output = children .map((childNode) => renderScreenReaderOutput(childNode, { - parentRole: parentRole ?? options.parentRole, + parentRole: parentRole, skipStaticElements: options.skipStaticElements, }), )