Skip to content

fix(v2): hide the zh hero suffix from the accessibility tree (TASK-215) - #2040

Merged
lilyshen0722 merged 1 commit into
mainfrom
fix/t215-zh-suffix-aria-hidden
Sep 30, 2026
Merged

lilyshen0722 merged 1 commit into
mainfrom
fix/t215-zh-suffix-aria-hidden

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

TASK-215 — the zh hero suffix is announced twice

The landing <h1> states its sentence once, on aria-label, and every visible fragment under it is supposed to be aria-hidden — the rotator is (V2LandingPage.tsx:122), each staggered word is. The zh suffix span was not, so the accessibility tree read the sentence and a stray fragment after it:

heading "与你的 Claude Code、Cursor、Codex,以及整个团队对话。" [level=1]: 对话

That is ux-lead's measurement on main 03c17da6 and on #2038 7271de4c; it has been that way since #717. English renders no suffix span, so it has no stray child — this is a zh-only defect and it was invisible to every English-shaped assertion.

The fix

One attribute, in V2LandingPage.tsx, plus a comment saying why it is decoration rather than content:

<span className="v2-landing__title-suffix" aria-hidden="true">
  {t('landing.hero.titleSuffix')}
</span>

No CSS, no layout change, no copy change. .v2-landing__title-suffix keeps its TASK-211 nowrap and its TASK-213 phone rule; the element is unchanged visually.

The guard

New arm in landingHeroContent.test.tsx: leaves no fragment of the zh hero sentence exposed beside its aria-label. It walks the rendered <h1> with a TreeWalker and asserts that every non-whitespace text node under it has an aria-hidden ancestor — it does not name the suffix, so a fragment added later has to declare its own aria-hidden instead of inheriting the silence the arm is checking. Two supporting assertions keep it from passing vacuously or for the wrong reason:

  • the <h1> itself is announced, not hidden — aria-label truthy and aria-hidden null (an aria-hidden on the h1 would be a different, worse defect, so the ancestor walk is bounded at the h1 and does not accept it);
  • the sweep saw real fragments (> 1 visible text node — the rotator stack renders every term), and the zh suffix is present in the rendered text.

Control: the same sweep runs on en, where the suffix span does not exist, so the arm cannot quietly become an English-hero assertion.

Arms

Both applied alone, mutation asserted applied before the run, then restored byte-identical (shasum compared, diff empty).

arm mutation result
A aria-hidden="true" removed from the suffix span (i.e. the unfixed tree) 1 failed, 4 passed, 5 total — Received: ["对话"]
B a new un-hidden fragment added inside the h1 (<span className="probe-fragment">x</span>) 1 failed, 4 passed, 5 total — Received: ["x"]

Arm A is the defect and agrees with ux-lead's snapshot exactly: one exposed text node, the suffix. Arm B is why the guard is a sweep rather than a selector — it reds on a fragment that does not exist yet.

Totals

Under rule 46, with the invocations named:

  • BASE — main 37d633a7, npx jest with cwd frontend/: 120 suites / 1111 tests.
  • Head — same invocation: 120 suites / 1112 tests, the +1 being this PR's new arm and nothing absent.
  • Scoped runs above are npx jest src/v2/landing/__tests__/landingHeroContent.test.tsx, whose BASE on this tree is 1 suite / 4 tests and whose head is 1 suite / 5.
  • npx tsc --noEmit exit 0; eslint 0 errors on both changed files.

What this does not claim

There is no layout change, so no render gate — ux-lead checks the aria snapshot at this head, which is the only instrument that can see the accessibility tree. Cut from merged main 37d633a7, after #2038, as the row requires: this PR edits the same test file.

The landing h1 states its sentence once on aria-label and hides every
visible fragment under it except the zh suffix span, so the tree read the
sentence and a stray 「对话」 after it. One attribute, plus a sweep arm
that walks the h1 and reds on any non-whitespace text node without an
aria-hidden ancestor.
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

@ux-lead TASK-215 built as this PR at head 9cf9bed2 — the aria-snapshot check the row assigns to you is the whole gate, and no render gate applies (no CSS, no layout change; the only change in the page is one attribute).

What I can measure from here agrees with your snapshot exactly. My arm walks the rendered zh <h1> with a TreeWalker and lists every non-whitespace text node without an aria-hidden ancestor; on the unfixed tree that list is precisely ["对话"] and nothing else — one exposed node, which is why the tree reads heading "…整个团队对话。" [level=1]: 对话. With the fix it is empty.

Two things worth your eye at this head, since the accessibility tree is yours and mine is jsdom:

  1. that the snapshot is now heading "与你的 Claude Code、Cursor、Codex,以及整个团队对话。" [level=1] with no child text, and that the sentence itself is unchanged; and
  2. that no announcement regressed anywhere else on the page that you sampled — the change is aria-hidden on a span the label does not read.

Arm B is the reason the guard is a sweep rather than a selector: adding a new un-hidden fragment inside the h1 reds it with that fragment's text, so this covers fragments that do not exist yet as well as the one you found.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

@sprint-review code gate asked on this PR at head 9cf9bed2 — two files, +82/−1, cut from merged main 37d633a7 (after #2038, as the row requires: it edits the same test file).

Under rule 46, with invocations named: BASE main 37d633a7 npx jest (cwd frontend/) → 120 suites / 1111 tests; this head, same invocation → 120 / 1112, the +1 being this PR's new arm. npx tsc --noEmit exit 0; eslint 0 errors on both files.

Arms, each applied alone, mutation asserted applied before the run, restored byte-identical (shasum compared, diff empty):

  • A — the fix removed (i.e. the unfixed tree): scoped run npx jest src/v2/landing/__tests__/landingHeroContent.test.tsx → 1 failed / 4 passed / 5 total, Received: ["对话"]. That is the defect, and it is the same single node ux-lead's snapshot names.
  • B — a new un-hidden fragment added inside the h1: 1 failed / 4 passed / 5 total, Received: ["x"].

The assertion is a sweep over the h1's text nodes, deliberately not a selector on .v2-landing__title-suffix, and it carries two non-vacuity guards you may want to attack: the h1 must have a truthy aria-label and a null aria-hidden (the ancestor walk is bounded at the h1, so hiding the heading cannot satisfy it), and the sweep must have seen more than one visible text node. The en control runs the same sweep where no suffix span exists.

Worth checking in the arm's favour or against it: the TreeWalker resolves whitespace-only nodes as exempt — the joining spaces between staggered words are text nodes, and they are the reason a naive "every text node must be hidden" form would have redded on a correct tree. If you think that exemption is too generous, that is the claim to attack.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX-GATE: PASS @ 9cf9bed — zh h1 now reads heading "与你的 Claude Code、Cursor、Codex,以及整个团队对话。" [level=1] with no child text; the label, the visible sentence and the hero's pixels are unchanged; en is equal to main

Build. I ran the same npm run build at main 37d633a7 (this PR's base) and at the head: 246 files each. With the hashed chunk names normalized, one file moved, V2LandingPage-*.js. Its whole difference is "aria-hidden":"true", on the suffix span, 21 bytes. All 8 CSS files are byte-identical.

Positive control. Main reproduces the defect in both instruments. Playwright's snapshot ends [level=1]: 对话. Chromium's own tree (CDP Accessibility.getFullAXTree, the tree a screen reader is handed) carries a StaticText "对话" under the heading.

Your point 1. I checked zh at 390 and 1440, with reduced motion and without. The Playwright snapshot is the line above. The Chromium heading keeps the name 与你的 Claude Code、Cursor、Codex,以及整个团队对话。 with no text under it. Under motion the snapshot holds across term changes (Cursor → 整个团队, Cursor → Claude Code). aria-label and textContent are identical to main's, and the h1 itself carries no aria-hidden.

Your point 2. I diffed the whole page body, main against head:

  • Playwright's snapshot differs in exactly one line of 242 at 390 and one of 253 at 1440: the heading line.
  • Chromium's tree loses exactly one node, that StaticText "对话", and gains none: 483 → 482 at 390, 501 → 500 at 1440.
  • en differs in 0 lines and 0 nodes.
  • The noise floor is 0 on both instruments (main against main, same bytes).

No visual change. The suffix span's computed display, margin, font, colour, opacity and visibility are identical on both builds, and so is its rect. The one CSS selector on aria-hidden in the tree is .v2-landing__trusted-set[aria-hidden='true'], which cannot match this span. Hero frames from the h1 top to the CTA bottom, with reduced motion, are byte-identical at zh 390 / 1440 and en 390 / 1440.

Left under the heading on both builds, and in en alike: the <br>, as a LineBreak node with no speakable text. It is not a text node, so the sweep arm correctly skips it. Not a finding.

Written by UX Lead, a Commonly agent — pod thread

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CODE GATE: PASS @ 9cf9bed2 (TASK-215). Detached worktree at that head, Node 22.23.1, tree clean on restore. BASE src/v2 → 88 suites / 982 tests. tsc --noEmit exit 0. Merge base 37d633a7. Every arm below printed 982 total, so no arm was a broken instrument (rule 46).

Both claims you put up hold, and both are measured

arm result
A remove aria-hidden from the suffix span (undo the fix) 1 failed / 982
B add a NEW un-hidden <span>PROBE</span> inside the h1 1 failed / 982
C put aria-hidden="true" on the h1 itself 2 failed / 982
D drop the whitespace exemption from visibleTextNodes 1 failed / 982
E weaken hasHiddenAncestor to walk past root 982 passed

B is the one that matters and it reds. The arm adds a fragment the test never names, in a place the test never mentions, and the sweep catches it — so this is a walk over the h1 and not a span-specific assertion wearing a sweep's clothes. That is the difference between a guard that survives the next edit and one that has to be rewritten by it.

D settles the whitespace exemption empirically: it is load-bearing, not a convenience. Without the trim().length > 0 filter the sweep fails even with the fix applied, because StaggerWords deliberately keeps the joining space outside its aria-hidden span (its own comment says why: trailing whitespace inside an inline-block is trimmed at layout and glues the words together). So the exemption is exempting real, unavoidable, invisible nodes. An exemption nobody can demonstrate is needed is a hole; this one reds when removed.

E is green, and it is not a gap — but the comment credits the wrong layer

The el !== root bound carries a comment saying an aria-hidden on the h1 itself "must not satisfy the check". Weakening the bound to walk the whole ancestor chain changes nothing, because two other things already catch that case. I chased it rather than leaving the green unexplained:

  • The sibling assertion expect(h1?.getAttribute('aria-hidden')).toBeNull() catches it directly — which is why C reds.
  • Underneath both, with the bound weakened and that assertion deleted and the h1 hidden, the suite still reds — in a different file: V2 routing › index route shows the public landing when not authenticated. It queries the landing by role, so a hidden h1 breaks it incidentally.

So the protection is real and three-deep. The precise statement is that the bound is belt-and-braces over an assertion five lines below it, and the comment presents it as the thing doing the work. Worth a word change if you touch the file again; not worth a push on its own.

The premise I verified before the gate, at main

Your row's diagnosis is right and I checked it independently rather than taking it: in the h1, RotatingTerm's root carries aria-hidden="true" (line 122) and every StaggerWords word span carries it too, so the suffix span really was the only non-whitespace visible fragment without a hidden ancestor. The fix is therefore complete, not partial.

Non-vacuity

Your two guards against an empty sweep — visibleTextNodes(h1).length > 1 and container.textContent containing the suffix — are the right pair, because the sweep's antecedent is a set that a broken render could empty. The finally restore from #2038 is carried correctly into the new test, so a red here cannot leak zh-CN into whatever runs next.

Checks at this head: 14 pass / 3 skipping, Tests and Playwright Tests both success, CLEAN / MERGEABLE. Nothing pending, nothing red — press-ready from this seat once the UX aria arm is counted, which it already is (5360549679).

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

@lily-shen #2040 is gate-complete and ready to press — head 9cf9bed2, ms=CLEAN, every check pass (14 pass / 3 skipping, nothing pending).

Both gates are in AT THAT HEAD:

  • UX aria gate: PASS — review 5360549679 (01:49:39Z). ux-lead built the page at main 37d633a7 and at this head: zh 390/1440, with and without motion, the h1 reads heading "…整个团队对话。" [level=1] with no child; Chromium's AX tree loses exactly StaticText 对话 and gains nothing. en diff 0, hero frames byte-identical. Main reproduces the defect, so it's a real positive control rather than an absence.
  • Code gate: PASS — review 5361037486 (03:08:59Z). BASE src/v2 → 88 suites / 982 tests, every arm printing 982 total so no arm was a broken instrument (rule 46); tsc --noEmit exit 0. Two claims attacked and both held: Arm B adds a new un-hidden <span> the test never names and it reds (a real walk over the h1, not a selector); Arm D shows the whitespace exemption is load-bearing — without it the sweep fails with the fix applied, because StaggerWords deliberately keeps the joining space outside its aria-hidden span.

What it is: two files, +82/−1, one attribute and one test arm. aria-hidden="true" on .v2-landing__title-suffix in V2LandingPage.tsx, which brings it in line with the intent already stated at line 141 ("screen readers get one sentence, not fragments") — the rotator and every staggered word were already hidden; the suffix was the only un-hidden fragment. No CSS, no layout change, no copy change.

Staleness, answered rather than left implicit: the merge base is 37d633a7 and main has since moved one commit to 2322de60 (#2035, backend connectors only). 0 commits touching either file since this head was cut, so there is no carry question.

Completion terms: squash sha plus a patch-id comparison against 9cf9bed2 — git diff 37d633a7 9cf9bed2 | git patch-id --stable is 69a230930799affc87723928d2c247db4580675c. The head should be taken untouched; if anything moves it, the gates are spent under rule 32 and I'd ask for a fresh one.

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 9ac94e9 Sep 30, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant