From 9cf9bed21f7ce562fd7a5078fd537b8b3d470c70 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:41:37 -0700 Subject: [PATCH] fix(v2): hide the zh hero suffix from the accessibility tree (TASK-215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/v2/landing/V2LandingPage.tsx | 7 +- .../__tests__/landingHeroContent.test.tsx | 76 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/frontend/src/v2/landing/V2LandingPage.tsx b/frontend/src/v2/landing/V2LandingPage.tsx index 9f8166a4c..cb3a3145f 100644 --- a/frontend/src/v2/landing/V2LandingPage.tsx +++ b/frontend/src/v2/landing/V2LandingPage.tsx @@ -390,7 +390,12 @@ const V2LandingPage: React.FC = () => {
{t('landing.hero.titleSuffix') && ( - {t('landing.hero.titleSuffix')} + // TASK-215: the sentence is stated once, on the h1's aria-label, + // so this span is decoration like the rotator above it. Without + // aria-hidden the tree announced the sentence AND a stray 「对话」. + )}

{t('landing.hero.lede')}

diff --git a/frontend/src/v2/landing/__tests__/landingHeroContent.test.tsx b/frontend/src/v2/landing/__tests__/landingHeroContent.test.tsx index 830bcf5c7..b4e3e3272 100644 --- a/frontend/src/v2/landing/__tests__/landingHeroContent.test.tsx +++ b/frontend/src/v2/landing/__tests__/landingHeroContent.test.tsx @@ -77,6 +77,32 @@ const renderLanding = () => render( , ); +/** Every text node under `root` that carries something a reader would see. */ +const visibleTextNodes = (root: Element): Text[] => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes: Text[] = []; + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if ((node.textContent ?? '').trim().length > 0) nodes.push(node); + } + return nodes; +}; + +/** + * True when some ancestor of `node` below `root` is `aria-hidden`. The bound + * matters: the hero h1 is deliberately NOT hidden (its `aria-label` is the + * whole sentence), so an `aria-hidden` on the h1 itself would be a different + * defect and must not satisfy the check. + */ +const hasHiddenAncestor = (node: Text, root: Element): boolean => { + let el = node.parentElement; + while (el && el !== root) { + if (el.getAttribute('aria-hidden') === 'true') return true; + el = el.parentElement; + } + return false; +}; + describe('V2LandingPage hero content (TASK-152)', () => { beforeEach(() => { jest.clearAllMocks(); @@ -158,4 +184,54 @@ describe('V2LandingPage hero content (TASK-152)', () => { await act(async () => { await i18n.changeLanguage('en'); }); } }); + + it('leaves no fragment of the zh hero sentence exposed beside its aria-label', async () => { + // TASK-215. The h1 states the sentence once, on `aria-label` + // (V2LandingPage.tsx: "screen readers get one sentence, not fragments"), + // and every fragment under it is supposed to be `aria-hidden`. The rotator + // (line 122) and each staggered word are; the zh suffix span was not, so + // the accessibility tree read the sentence AND a stray 「对话」 after it — + // `heading "与你的 …, 以及整个团队对话。" [level=1]: 对话` — on main since + // #717, on every renderer. This walks the h1 rather than naming the span, + // so a NEW fragment added later has to declare its own aria-hidden instead + // of inheriting the silence this arm is checking. + const zhSuffix = (((readLocale('zh-CN.json').landing as TranslationTree).hero as TranslationTree) + .titleSuffix) as string; + expect(zhSuffix.length).toBeGreaterThan(0); + + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + const zh = renderLanding(); + const h1 = zh.container.querySelector('h1.v2-landing__title'); + expect(h1).not.toBeNull(); + // The premise the sweep rests on: the sentence is on the label and the + // heading itself is announced, not hidden. + expect(h1?.getAttribute('aria-label')).toBeTruthy(); + expect(h1?.getAttribute('aria-hidden')).toBeNull(); + + const exposed = visibleTextNodes(h1 as Element) + .filter((node) => !hasHiddenAncestor(node, h1 as Element)) + .map((node) => node.textContent); + expect(exposed).toEqual([]); + + // Non-vacuity: the sweep has to be looking at a heading that really does + // carry the visible fragments (the rotator stack renders every term), or + // an empty h1 would pass it. + expect(visibleTextNodes(h1 as Element).length).toBeGreaterThan(1); + expect(zh.container.textContent).toContain(zhSuffix); + zh.unmount(); + + // en ships an empty suffix and the rotator is hidden there too, so the + // control keeps this from becoming an en-shaped assertion. + await act(async () => { await i18n.changeLanguage('en'); }); + const en = renderLanding(); + const enH1 = en.container.querySelector('h1.v2-landing__title'); + expect( + visibleTextNodes(enH1 as Element).filter((node) => !hasHiddenAncestor(node, enH1 as Element)), + ).toEqual([]); + en.unmount(); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); });