fix(runtime): dedup screen-reader role against immediate parent only (Ink parity, G22) (#51)

* 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 <noreply@anthropic.com>

* chore(parity): ledger — G22 pr-open, reconcile G21 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:
Yunfei He
2026-05-30 07:10:17 +08:00
committed by GitHub
parent fb6fe16022
commit 0d847f0e48
3 changed files with 102 additions and 3 deletions
+2 -2
View File
@@ -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 <Transform> in <Text> 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 <Transform> in <Text> 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 | <Transform> under <Box> SR-joins children with newline; Ink concatenates | P3 | todo | — | — |
| G24 | render-lifecycle-reconciler | Renders ALL <Static> nodes; Ink renders only the most-recent staticNode (multi-Static support — candidate) | P3 | candidate | — (see ink-parity.md) | — |
@@ -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", () => {
+6 -1
View File
@@ -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,
}),
)