Adjacent-Identical-Text False Positive
Design Log 185 — a11y adjacent-identical-text false positive on scoped bindings (label/select, options/modifiers)
Written for AI agents. See Log Methodology Note below for details.
Status: Bug report — awaiting fix
Follow-on to DL#184 (a11y adjacent-identical-text false positive on forEach siblings). DL#184 fixed the case where the two adjacent elements are themselves
forEachloops (items/loadedItems). The examples below hit the same rule but through two paths its proposed fix does not cover: (a) adjacent plain elements whose text comes from a childforEach/scope (<label>{name}</label>next to<select><option forEach>{name}</option></select>), and (b) adjacentif-guarded container<div>s whose text aggregates from descendantforEachloops (product-optionsnext toproduct-modifiers). It also surfaces a separate defect ingetVisibleText(descendantaria-hiddenis ignored).
Background
The a11y-validator rule checkDuplicateAdjacentText warns when two adjacent element
siblings have identical visible text ("screen reader announces it twice").
Source: packages/plugins/a11y-validator/lib/validators/a11y-validator.ts
checkDuplicateAdjacentText— lines 558–590 (comparesgetVisibleText(current) === getVisibleText(next))getVisibleText— lines 553–556 (el.textContent, minus the element's ownaria-hidden)
DL#184 added (or proposes) a guard: skip the pair when either element itself carries a
forEach attribute. That is correct but narrow — it only matches when the repeating element
is the loop.
Problem
The product-detail pages in the design-to-code examples embed a standard Wix-stores options
UI: for each product option (and each modifier), a label/title plus a <select> of
choices. This produces two false-positive shapes the DL#184 guard misses.
Shape A — label adjacent to a select whose options loop a child collection
<!-- one option group -->
<div class="option-group" forEach="productPage.options" trackBy="_id">
<label class="option-label" for="option-{_id}">{name}</label>
<!-- option-group name, e.g. "Size" -->
<select id="option-{_id}" class="select" if="optionRenderType == TEXT_CHOICES" ...>
<option forEach="choices" trackBy="choiceId" value="{choiceId}">{name}</option>
<!-- choice name, e.g. "Small" -->
</select>
</div>
getVisibleText(<label>) = "{name}"; getVisibleText(<select>) = "{name}" (the child
<option>'s text). They are adjacent siblings and neither element itself has a forEach
attribute (the loop is on the parent option-group; the option loop is a descendant of the
<select>). So the DL#184 forEach-on-self guard does not fire, and the rule warns:
⚠ Adjacent <label> and <select> have identical text "{name}" — screen readers will announce it twice
At runtime the two {name} bindings resolve in different scopes — productPage.options[i].name
("Size") vs choices[j].name ("Small") — so nothing is announced twice. The identity is purely
an artifact of comparing unexpanded template tokens.
Shape B — adjacent if-guarded containers whose text aggregates from descendant loops
<div class="product-options" if="productPage.options">... forEach="productPage.options" ...</div>
<div class="product-modifiers" if="productPage.modifiers">
... forEach="productPage.modifiers" ...
</div>
getVisibleText on each <div> returns its whole subtree's textContent, which collapses to
"{name} {name}" (option-title {name} + option {name}) for both divs. They are adjacent,
and neither <div> itself carries forEach (they carry if; the loops are descendants). The
rule warns:
⚠ Adjacent <div> and <div> have identical text "{name} {name}" — screen readers will announce it twice
Again false: productPage.options and productPage.modifiers are different collections.
Secondary defect — getVisibleText ignores descendant aria-hidden
jay-store-light already applies the documented remediation — it uses a non-<label>
title and marks it aria-hidden:
<div class="options-section" if="productPage.options">
<div class="option-block" forEach="productPage.options" trackBy="_id">
<div class="option-title" aria-hidden="true">{name}</div>
<!-- decorative duplicate, hidden -->
<select class="select" ... aria-label="{name}">
<option forEach="choices">{name}</option>
</select>
...
</div>
</div>
It still gets the Shape-B <div> and <div> warning, because getVisibleText
(a11y-validator.ts:554) only returns '' when the element passed in has
aria-hidden="true" — it does not strip aria-hidden descendants from the aggregated
textContent. So the author's aria-hidden="true" on option-title is disregarded when the
parent options-section divs are compared. The author has done everything right and cannot
silence the warning.
Reproduction (design-to-code examples repo)
Shape A + Shape B (uses <label>):
jay-onsko-shop/src/pages/product/[slug]/page.jay-html<div class="product-options" if="productPage.options">— line 307<label class="option-label" for="option-{_id}">{name}</label>— line 309<select id="option-{_id}" ...>with<option forEach="choices">{name}</option>— lines 312–317<div class="product-modifiers" if="productPage.modifiers">— line 334 (adjacent to product-options)
jay-studio-store/src/pages/products/[slug]/page.jay-html— same options/modifiers patternmisprint-goods-jay/src/pages/product/[slug]/page.jay-html— same options/modifiers pattern
Shape B only, with the aria-hidden remediation already applied (secondary defect):
jay-store-light/src/pages/products/ceramic-flower-vase/page.jay-html<div class="options-section" if="productPage.options">— line 450<div class="option-title" aria-hidden="true">{name}</div>— line 452 (ignored by getVisibleText)<div class="options-section" if="productPage.modifiers">— line 475 (adjacent)
These are the last remaining a11y warnings in all four examples after every genuine
a11y/SEO/wix-media/design-system warning was fixed. As DL#184 notes, the a11y-validator has
no suppression mechanism, and neither aria-hidden nor text differentiation is
appropriate for legitimate, distinct-content dynamic UI.
Root Cause
checkDuplicateAdjacentText treats the static jay-html template as final DOM. The DL#184
guard only inspects forEach on the two compared elements themselves. But the repeating /
scoping binding is frequently on a child (<option forEach>, swatch buttons) or an
ancestor (option-group forEach), and the compared element carries only if or nothing.
Comparing collapsed template text across different binding scopes is meaningless.
Separately, getVisibleText only checks aria-hidden on the passed element, so
aria-hidden on any descendant is not honored — the aggregated textContent used for
container-vs-container comparison still includes hidden decorative text.
Proposed Fix
Update: fix 1 below is now handled by DL#184's revised rule — skip the pair when either compared element's subtree (self or any descendant) carries a dynamic directive (
forEach/slowForEach/if/when-*). Both Shape A (the<select>has aforEachdescendant) and Shape B (the<div>s carryifand containforEachdescendants) bail under that single guard — no separate{binding}regex or ancestor-walk is needed. This section is kept for the reproduction record; fix 2 (descendantaria-hidden) and fix 3 (suppression) remain the distinct contributions of this log.
(Superseded by DL#184.)
Broaden the guard to descendants/ancestors via aDL#184's{binding}regex + subtree/ancestryforEachwalk.isDynamicSubtree(current) || isDynamicSubtree(next)guard already covers every Shape A/B case here. Note DL#184 deliberately scopes the check to subtree, not ancestors: two simple same-scope siblings under a sharedforEachancestor render identical text each iteration and should still warn — so the ancestor-walk originally proposed here would have wrongly suppressed a real duplicate.Fix
getVisibleTextto honor descendantaria-hidden. Instead of rawel.textContent, walk the subtree and drop the text of any node witharia-hidden="true". This makes the author's remediation (aria-hiddenon the decorative duplicate) actually reduce the compared text, and matches how assistive tech computes the accessible name.Add a suppression mechanism to the a11y-validator (honor
<script type="application/jay-validations">as wix-media/SEO already do), as DL#184's secondary recommendation notes — an escape hatch for residual edge cases across all a11y rules.
Fix 1 lands as part of DL#184 (the unified dynamic-subtree guard). Fix 2 is this log's own change and is independent; fix 3 is the safety net.
Verification
- DL#184's
isDynamicSubtreeguard is in place; apply thegetVisibleTextdescendant-aria-hiddenfix (fix 2) here. - Rebuild the a11y-validator plugin.
npm run validatein each ofjay-onsko-shop,jay-studio-store,misprint-goods-jay,jay-store-light— the product-detail options/modifiers pages report 0 a11y adjacent-identical-text warnings, with no template changes.- Regression: a genuine adjacent duplicate with static text and no loop scope
(e.g. two static
<span>★</span>, or a visible label duplicating an adjacent control's static text) must still warn. Confirm against existing a11y-validator unit tests. - Regression for fix 2: a decorative descendant marked
aria-hidden="true"is excluded from the compared text (a container whose only "duplicate" text is inside anaria-hiddendescendant does not warn).
Notes
- The four examples need no template changes once the validator is fixed; the
for/idlabel↔select association added injay-onsko-shop(a genuine a11y improvement) is orthogonal and stays. - Related: DL#184 (forEach-sibling case + suppression recommendation — this log extends it), DL#147 (a11y rules catalog), DL#145 (pluggable validation), DL#166 (form/label rules).
Implementation Results (2026-09-08)
Status: implemented (in packages/plugins/a11y-validator/lib/validators/a11y-validator.ts,
alongside DL#184).
- Fix 1 (guard broadening) — landed as DL#184's unified
isDynamicSubtreeguard; both Shape A (the<select>has aforEachdescendant) and Shape B (theif-divs containforEachdescendants) now bail under it. No separate{binding}regex or ancestor-walk was added. - Fix 2 (descendant
aria-hidden) —getVisibleTextnow delegates to a recursivecollectVisibleText(node)that returns''for any node (or subtree) witharia-hidden="true", then trims/normalizes whitespace. Previously it only checkedaria-hiddenon the passed element. - Fix 3 (suppression) — implemented, not deferred. The plumbing was trivial and per-validator: the
<script type="application/jay-validations">YAML is parsed centrally (compiler-jay-html/jay-html-parser.ts:parseValidationOverrides) and delivered onctx.validationOverrides. AddedisSuppressed(ctx, rule)reading a newa11y:namespace (ctx.validationOverrides?.a11y?.[rule] === true), mirroring the seo/design-system shape, and wired it into this one rule viaallow-adjacent-duplicate-text(early-return when set). Selective, matching how seo/design-system apply suppression per-rule. Extending thea11y:namespace to other rules is a mechanical follow-up if wanted.
Tests (test/validators/a11y-validator.test.ts; no toContain): duplicate only inside an
aria-hidden descendant → 0; visible duplicate alongside an aria-hidden decorative node → still flags;
allow-adjacent-duplicate-text: true → 0; override absent → still flags.
Results: a11y-validator suite 75/75 (combined with DL#184); tsc clean; monorepo yarn confirm
green.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.