Design challenges

September 14, 2026 · View on GitHub

A running log of engine design decisions worth re-examining: cases where an existing choice turned out to conflict with a ground-truth source (usually the W3C ACT rules test corpus), or just looks questionable on a second look. Not all of these are bugs; some are tradeoffs that deserve a second opinion before being confirmed or overturned. Each entry has the decision as it stands, why it's being questioned, and its current status. Settled entries move to Decided at the bottom, with the reasoning kept, since a decision is only useful later if the argument behind it survives with it.

Method

Before changing behavior that already ships, or agreeing that a challenge to it is right, establish why the current behavior exists rather than assuming it was either careless or considered.

  • Read the history first. git log -L <start>,<end>:<file> (or git log -S<token>) on the exact lines, and check whether the commit message, this document, RULE_AUTHORING.md, or CHANGELOG.md already recorded a reason. A comment that states what the code does is not evidence of why it does it — say explicitly when no rationale can be found instead of inventing one to fill the gap.
  • Check for a second implementation of the same concept. A question this engine has needed to answer more than once (eligibility, focusability, naming, inertness) is sometimes reimplemented locally inside one rule rather than shared through dom-helpers.js. When two independent implementations disagree on the same input, that disagreement is stronger evidence of a real problem than either implementation's own comment defending itself.
  • Weigh it against ground truth, not intuition: the WCAG Understanding documents and Techniques, the ACT rules corpus (this repo's primary source, see the intro above), and the HTML/ARIA spec text for the exact mechanism in question — what inert, aria-hidden, or a native role default actually do, not what seems reasonable.
  • Compare against other accessibility testing engines where practical, as one more data point on how the same ambiguity is usually resolved in the field — never as an authority on its own, and never named or quoted in a rule's comments, commits, or docs (this repo never names competing engines).
  • State the counter-argument before concluding. Write down why the current behavior might be right, and why it hasn't already been revisited, before deciding it should change. "Nobody thought this through" and "this was already considered and rejected" call for different next steps.
  • Land the decision somewhere. Fix it with a CHANGELOG.md entry that states the reasoning, or add/update an entry in this file's Open or Decided section, so the argument survives with the decision the next time someone re-examines it.
  • After changing a rule's behavior, regenerate every generated artifact that describes it, not just the code and tests: npm run docs:rule-catalog, npm run coverage, npm run fixtures:index, npm run fixtures:markers, npm run finding-ids, and npm run docs:rule-review (the by-hand review page). None of these run automatically from a source edit, and a stale one is easy to miss since nothing fails loudly except fixtures:markers:check/coverage:check/finding-ids's own test.

Open

label-in-name compares against accessibility-tree text, where ACT uses visible inner text

Decision as it stands: label-in-name.js builds the element's visible label from text nodes whose parent is accessibility-tree eligible, which drops aria-hidden subtrees and keeps text that CSS has hidden visually. The aria-hidden half is reasoned in the rule's own comment: an <i aria-hidden="true"> icon-font ligature renders as a glyph, not as the literal word in the DOM, so counting its text would flag every icon-only button named by aria-label.

Why it's being questioned: ACT 2ee8b8 is explicit that its "visible inner text" is a rendering property, not an accessibility-tree one, and three of its examples turn on the difference: <a aria-label="Download specification">Download <span aria-hidden="true">gizmo</span> specification</a> fails ACT (the word "gizmo" is on screen, whatever aria-hidden says) and passes here; a clip-path: inset(50%) visually-hidden span passes ACT (nothing is rendered) and fails here; and <div style="display: inline"> children concatenate into one word for ACT ("ACT" from three inline divs) while we read them apart. All three point the same way: the label a sighted user speaks comes from what is painted, not from what the accessibility tree carries. The icon-font case the current behavior was built for is real, but aria-hidden is a coarse stand-in for it, since the attribute says "don't expose this," not "this doesn't render as words."

Why this hasn't been changed yet: "visible inner text" needs a real rendering model: per-node display resolution for the concatenation rule, plus the visually-hidden detection (clip-path, clip, 1px boxes, off-screen positioning) the engine's offscreen heuristic only partly covers. It's a shared-helper change with reach beyond this rule.

Caveat added 2026-08-19: re-fetching ACT 2ee8b8's live rule page while closing the icon-font entry below turned up only 13 published examples, none of them the aria-hidden/clip-path/inline-concatenation shapes described above. Those specific claims don't currently reproduce against the live corpus (same stale-local-checkout pattern as the bc4a75 applicability question, now in Decided). The architectural critique (accessibility-tree text vs. true rendered text) may still be sound, but it is currently untested by ACT ground truth rather than confirmed by it.

Status: open, unresolved as of 2026-08-19. Deprioritized pending a live ACT example that actually exercises it.

Five ARIA roles that require an accessible name have no rule covering them at all

Decision as it stands: with aria-role-name-present now scoped to WAI-ARIA's "Accessible Name Required: True" roles (see the entry in Decided below), the engine's naming coverage is principled but incomplete. Deriving the full set from aria-query (name-required, and name-from-author-only, so subtree text cannot rescue it) turns up five roles no rule in this repo evaluates: table, tabpanel, treegrid, application and marquee. The DPUB and Graphics module roles in the same bucket (doc-biblioentry, doc-pagebreak, doc-part, graphics-document, graphics-symbol) are also uncovered for naming, though graphics-* are covered for text alternatives by svg-image-text-alternative-present.

Why it's being questioned: tabpanel is the pointed one. The rule was fixed by dropping tablist, which the spec does not require a name for, while tabpanel, which it does, goes unchecked. The same predicate that justified the removal argues for the addition, and table is common enough in real markup that the gap is not theoretical.

Why this hasn't been changed yet: each added role is new serious failures on scans that are green today, which is a different kind of change from removing false positives; it needs its own changelog entry and a baseline conversation with integrators, not a quiet ride-along. application and marquee are also rare enough, and odd enough, to deserve their own look rather than a bulk add; treegrid overlaps grid, which is already covered.

Status: open as of 2026-08-21, deferred, not forgotten. The exclusion list and its reasoning live in scripts/generate-aria-tables.js, next to the generated set.

Decided

aria-required-children failed a container for being empty, when its sibling already owns the question of whether the contents are valid, now advisory

Decision as it stands (before the change): the rule failed any container role with a "required owned elements" entry that had no descendant (or aria-owns target) carrying one of those roles. An empty <div role="list">, a role="tablist" before its tabs arrive, a role="rowgroup" with no rows: all fail at moderate, mapped to SC 1.3.1.

Why it was questioned: the rule asks one thing, whether the required content is PRESENT. Whether the content a container does own is VALID is aria-prohibited-children's decision, and that rule fails independently. Absence conveys nothing false: an empty role="list" is announced as a list with no items, which is exactly what it is. The engine's own native-HTML rules already work this way, which made the ARIA side incoherent by comparison: <ul></ul> passes and <div role="list"></div> failed, same structure, same emptiness, opposite verdicts, with nothing in WCAG distinguishing them. ACT bc4a75, the authority this rule's fail rests on, turns out not to cover the shape at all: its Expectation is "each test target only owns elements with a semantic role from the required owned element list", which an empty container satisfies vacuously, and it publishes no empty-container example in either direction. The engine's clean run against that corpus was therefore silent about this case rather than confirming it.

What was weighed: three predicates were on the table. Cap the rule at cantTell outright; keep a fail for a container that owns roles but none of the required ones; or the stricter line used elsewhere in the industry, cantTell only when the container owns no content whatsoever, so a container of unroled elements still fails. The middle option was dropped once it was clear that every shape it would fail is already failed by aria-prohibited-children, making it a second rule agreeing with the first rather than a decision of its own, against this repo's one-rule-one-decision principle. The strict option was dropped because a container of unroled elements is equally "a broken list" and "an empty list with content inside it", and static markup does not settle which.

Decision (2026-08-28): the rule reports cantTell for every finding and can no longer fail. Applicability, the aria-busy escape hatch, accessibility-tree eligibility, aria-owns resolution and slot expansion are all unchanged, as is aria-prohibited-children.

Accepted cost: <div role="list"><div>Item one</div><div>Item two</div></div>, a list whose items never got their role, is now reported for review rather than failed, and no other rule fails it. That is the only shape that loses a failure; the fixture carries it as case 09 and a test pins the sibling rule still failing a genuinely disallowed child, so the safety net this depends on cannot be removed quietly.

Status: resolved 2026-08-28.

The aria-* family reported ARIA-spec conformance as a WCAG 4.1.2 failure, where ACT's own mapping calls most of it "not required for conformance", now graded

Decision as it stands (before the change): 13 of the 16 automatic aria-* rules declared wcagSc: ['4.1.2'] at level A (the other three were re-mapped to 1.3.1, see Status), normative: true (no rule anywhere in this repo sets normative: false) and defaultConfidence: 'high' — the exception being aria-required-parent, which is medium and still emits a flat fail. Eleven of the sixteen emit a flat fail with no second tier; five grade into a cantTell tier (aria-allowed-attr, aria-deprecated-role, aria-prohibited-attr, aria-valid-attr-value, aria-hidden-focus). Seventeen atomic rules feed one composite, wcag-4.1.2-aria-validity, whose own description says it rolls up checks "that ARIA role and attribute usage conforms to the WAI-ARIA specification"; any single contributor fail makes that SC verdict fail.

Why it's being questioned: the strictness is not in the detection logic, which is conservative and well guarded — aria-required-children honours aria-busy, accessibility-tree eligibility, aria-owns and slot projection; REQUIRED_PROPS_BY_ROLE deliberately omits context-dependent properties; ALLOWED_ROLES_BY_ELEMENT treats unmodelled elements as unconstrained. It is entirely in the verdict layer, and ACT's own Accessibility Requirements Mapping disagrees with it for most of the family:

ACT ruleThis repoACT's primary requirementWCAG status per ACT
4e8ab6 required states/propertiesaria-required-attrARIA5 technique; ARIA 1.2 §5.2.2not required for WCAG conformance; 1.3.1/4.1.2 are secondary and "less strict", they "allow for fallback default values that may make some failures acceptable"
5c01ea property permittedaria-allowed-attrARIA5 technique; ARIA 1.2 §8.6not required for WCAG conformance; 1.3.1/4.1.2 secondary, "less strict"
5f99a7 attribute definedaria-valid-attrnone1.3.1/4.1.2 secondary, "less strict"
6a7281 valid valuearia-valid-attr-valuenone"not required for conformance to WCAG 2.1 at any level"
674b10 role has valid valuearia-roles-validARIA4, G108 techniquesnot required for conformance to any W3C recommendation; 4.1.2 "can be satisfied through the implicit role"
bc4a75 required owned elementsaria-required-children, aria-prohibited-children1.3.1 Info and Relationshipsrequired for conformance, on 1.3.1
ff89c9 required context rolearia-required-parent1.3.1 Info and Relationshipsrequired for conformance, on 1.3.1

Two separate problems fall out of that table. Five of the seven are ARIA author requirements that WCAG does not mandate, reported here as level-A WCAG failures at high confidence. The other two are conformance-required, but on 1.3.1, not the 4.1.2 all three rules declare: a plain SC misattribution that lands the verdict on the wrong criterion (the level is unaffected, both are A). aria-allowed-role is the weakest claim in the family — no ACT rule covers it and no external source maps ARIA-in-HTML's permitted-roles table to a Success Criterion, yet it fails at high confidence. Six ARIA rules have no ACT counterpart at all (ACT_RULE_MAPPING.md's no-ground-truth list), which is exactly where the fail decision has nothing external checking it.

The distinction ACT is drawing is whether the exposed name, role and value survive the violation. <button role="buton"> is still exposed as a button; <div role="checkbox"> with no aria-checked gets ARIA's own false default, and whether that default is wrong is aria-checked-state-mismatch's question, already capped at cantTell; <div role="heading"> with no aria-level is still a heading at the user agent's default level; aria-brailleroledescription without aria-roledescription reaches no user at all and is currently serious. Against that, aria-label on a roleless <span> genuinely loses the name — and that case is already graded, by aria-prohibited-attr, on precisely this reasoning.

The engine's stated bar is that fail stays reserved for deterministic violations. The family currently reads that as high confidence that the ARIA specification was violated, which it reliably is; the argument here is that it should mean high confidence that the Success Criterion the rule names is failed, which for most of the family it is not.

What the graded family looks like: aria-hidden-body, aria-hidden-focus and aria-role-name-present stay fail unchanged — each is a present barrier, not a spec citation. aria-required-children/-prohibited-children/-required-parent stay fail on ACT's authority, and are the part of this entry already acted on. Four rules gain a second tier on the fallback-survival axis: aria-roles-valid (fail only where the host has no implicit role to fall back on, a distinction getNativeRoleForElement already computes), aria-required-attr (fail for slider/scrollbar/meter missing aria-valuenow, which is a genuinely absent value; cantTell for the roles ARIA gives a default), aria-valid-attr (an undefined attribute is inert, so fail only where the misspelling plausibly cost the element a name it does not otherwise have), and aria-braille-equivalent (the aria-brailleroledescription half has no user-facing consequence and should not be serious). The remaining question is whether the family needs a third outcome tier altogether — an ARIA-conformance finding that is real, reported, and does not claim a WCAG SC, expressible today as normative: false plus a WAI-ARIA normativeMappings entry, which would also keep it out of the SC composite.

What was weighed against changing it: it moves scans that are red today to yellow, which is a louder change for existing baselines than removing false positives ever was, and it weakens what integrators can gate CI on: cantTell is not enforceable the way fail is. There is also a real argument for the status quo — an ARIA spec violation is a decent leading indicator even when it is not itself a barrier, since undefined behaviour differs across assistive technology and today's harmless default is tomorrow's regression. The rebuttal is that the package's premise is telling you what it cannot tell you, and a graded cantTell carrying a reason code says more than a fail that overstates its own authority. Every mechanism this needs already ships: aria-deprecated-role grades on the strength of the spec's own statement (MUST NOT versus SHOULD NOT), resolveTieredOutcome carries both tiers, the 4.1.1 handling already coerces an out-of-scope SC to cantTell with a wcagVersionScope field, and POLICY_CONTRACTS exposes allowedOutcomes/allowedConfidence. What is missing is the second axis — grading on whether name, role and value survive, not only on how strongly ARIA words the requirement.

Two by-products found while reading, both smaller and independently fixable: aria-hidden-body and aria-role-name-present were missing the aria tag the rest of the family carries, so filtering by tag leaked; both carry it now. The second was wrong as written: aria-required-parent is not the only rule emitting a flat fail at medium confidence, it is one of six, three of them outside the ARIA family, so there is no anomaly at that rule to fix. What the reading did turn up is a documented contradiction, since OUTPUT_SCHEMA.md defined fail as a high-confidence outcome while six automatic rules shipped fail at medium; the outcome describes the decision procedure and confidence the model it decides against, and the docs say so now. aria-required-parent separately gained the aria-busy ancestor guard its two siblings already had. nested-interactive-controls-absent is untagged for aria too, left alone on purpose, since it covers native nesting as much as the ARIA kind.

Status: resolved 2026-08-28. The six rules above now grade, aria-required-attr and aria-roles-valid on a computed predicate and the other four wholesale, with the two implicit-value cases generated from aria-query so the tiers cannot drift from the spec by hand. Across the 137-fixture corpus the change moves 12 rule verdicts and one composite (wcag-4.1.2-aria-validity, fail to cantTell on 13 fixtures); no occurrence count changes anywhere, so nothing stopped being reported. ACT 674b10, 4e8ab6 and 5f99a7 still run clean against their live corpora (25 cases, 0 mismatches), since the checker counts a cantTell carrying occurrences as satisfying a "failed" expectation.

Then the remaining overstatement went too. aria-allowed-role declared SC 4.1.2 with no ACT rule and no source mapping ARIA-in-HTML's permitted-roles table to a criterion; it now declares none, tagged best-practice with wcagSc: [], out of wcag-4.1.2-aria-validity and out of the facet registry. It is this engine's first automatic rule with no WCAG mapping, which meta.normative could not have expressed (that field is inert) and which needed the composites catalog, the facet registry and the level tags edited by hand. The rule still runs by default, still reports the same findings; on the fixture corpus the only effect is that wcag-4.1.2-aria-validity reaches pass on 8 fixtures where an ARIA-in-HTML nit was the sole remaining contributor. docs/RULE_TAXONOMY.md §1.1 was rewritten alongside it: the automatic/manual line is whether a rule can decide, not which outcome it reports, so a deterministic rule reporting cantTell is still automatic.

contrast-minimum/contrast-enhanced treated symbol-only text as real text needing a contrast ratio, fixed

Decision as it stands (before the fix): the shared text scan's applicability gate (isNonEmptyText in getTextScan(), src/core/contrast-helpers.js) only checked for non-whitespace characters. A text node made entirely of punctuation/symbol glyphs (----=====+++...±±±±@@@@@@@@) counted the same as real words.

Why it was questioned: ACT afw4f7/09o5cg's own applicability is scoped to text that "expresses something in human language," and their own passed example for the "environment-dependent" bucket this was filed under is exactly a paragraph of pure symbols. Nothing environment-dependent about it: a plain applicability gap.

Decision (2026-08-19): isNonEmptyText now also requires at least one Unicode letter or number (\p{L}/\p{N}) somewhere in the text node, applied at both the text-node walk and the <input type=submit|button|reset> value-attribute path that shares the same gate. Digits-only text ("42") still counts and is still checked; only text with zero letters or digits at all is exempt.

Status: resolved 2026-08-19. afw4f7 drops from 6 to 5 mismatches, 09o5cg from 5 to 4.

css-orientation-lock required an EXACT 90/270-degree rotation, when its own comments already claimed "approximately," fixed

Decision as it stands (before the fix): isLockingRotation's comment claimed to flag "~90/~270 degrees," but the actual code did Math.abs(abs - 90) % 90 <= 0. Modulo of a non-negative number is <= 0 only when it's exactly 0, so this only ever matched a rotation that was an exact multiple of 90. The 3 live mismatches this produced were filed as a single "env/harness limit: jsdom's CSS parser drops @media(orientation) rules using rad units or matrix3d()."

Why it was questioned: directly parsing both cited stylesheets with jsdom (outside this engine entirely) showed jsdom parses rotate(1.5708rad) and matrix3d(...) correctly; style.transform comes back with both intact. Neither is a jsdom limitation. What's actually happening: 1.5708rad converts to 90.0000210... degrees (floating-point remainder from the radian conversion), and ACT's own failed example uses an inexact 92.5deg. Both are "approximately 90" in every practical sense, but neither is === 90, so the exact-modulo check silently passed both.

Decision (2026-08-19): replaced the exact-equality check with a real tolerance window (±5 degrees) around the normalized position, fixing both the floating-point case and the inexact one (the "not a jsdom limitation" 2 of the original 3 mismatches). The third, matrix3d(), genuinely is a documented scope limit (this rule doesn't decompose transform matrices into an equivalent angle) and stays as-is; that reasoning was already correct, just miscategorized as "jsdom" alongside the two that weren't.

Status: resolved 2026-08-19. b33eff drops from 3 to 1 mismatch (the matrix3d() case, accepted at the time; later closed too, see below).

css-orientation-lock deferred matrix()/matrix3d()/rotate3d() entirely, though the remaining case is an unambiguous pure rotation, fixed

Decision as it stood: the entry above accepted matrix3d() as a genuine scope limit: decomposing a general 3D transform matrix into an equivalent rotation angle was filed alongside table-th-has-data-cells's narrower positional-header algorithm as "higher-complexity/lower-value" work.

Why it was questioned: the one live mismatch this produced, ACT's own transform: matrix3d(0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1), isn't a general 3D matrix at all: its 3rd/4th columns match the identity (no translation, no perspective, no rotation around another axis) and its top-left 2x2 block is a unit-length, orthogonal rotation. That combination has exactly one equivalent angle, computable with atan2, not a decomposition that could be ambiguous or require a guess.

Decision (2026-08-19): rotateDegreesFromTransform now also parses rotate3d(x, y, z, angle) directly (trivial when x/y are ~0 and z is ~±1) and matrix()/matrix3d() via a 2x2 rotation check (decomposeMatrix2dRotation) and, for matrix3d, an identity check on the other two columns first (decomposeMatrix3dZRotation). Anything that isn't a pure Z rotation (scale, skew, translation, perspective, a rotation combined with another axis) contributes 0 degrees, same as an unrecognized value; the rule still never guesses at an angle a general transform doesn't uniquely have.

Status: resolved 2026-08-19. b33eff runs clean (0/10).

iframe-focusable-content never resolved a srcdoc iframe's content, and never exempted tiny "tracking pixel" iframes, both fixed

Decision as it stands (before the fix): the rule's contentDoc = el.contentDocument || null resolution was the only path to an embedded document; it was documented as an "env/harness limit: jsdom doesn't populate iframe.contentDocument from a srcdoc attribute; a real browser does," implying nothing could be done about it in this codebase specifically.

Why it was questioned: that framing only holds for the test-fetcher's own jsdom instance. This library's own Node/jsdom integration path (docs/INTEGRATION.md Pattern 1) is a real, documented way consumers run this engine, and jsdom's srcdoc gap affects them identically, not just this repo's test harness. el.getAttribute('srcdoc') is a plain string this engine can read and parse itself, independent of whatever the host DOM implementation does with it.

Decision (2026-08-19): two changes, both scoped to this rule. (1) When the live contentDocument looks empty and a srcdoc attribute is present, its HTML string is parsed via DOMParser as a static fallback; no rendering pipeline needed, and every downstream helper in this file (isRenderedInDoc, probeImmediateFocusRedirect) already degrades gracefully when handed a detached, defaultView-less document. (2) Fixing that surfaced a second, previously-masked gap: ACT akn7bn's own Expectation requires the focusable content to also be visible, and a width/height ≤ 2px iframe (the "tracking pixel" pattern, ACT's own passed example) can't render anything perceptible. That exemption is now checked via the iframe's own HTML attributes, a static signal that doesn't need real layout.

Status: resolved 2026-08-19. akn7bn runs clean (0/6).

bypass-blocks-present's heading mechanism credited a screen-reader-only heading, when ACT requires it to be visible, fixed

Decision as it stands (before the fix): hasHeading() credited any <h1>-<h6>/[role="heading"] that was included in the accessibility tree; an off-screen-positioned, clipped, opacity:0, or zero-size-overflow-hidden heading counted the same as a fully visible one. This mismatch was never individually triaged; it sat inside ye5d6e/047fe0's combined "1, 2" mismatch count, both filed under one blanket "deliberate leniency" reason that only actually described a different shape (a heading positioned inside the repeated content it's supposed to be an escape from).

Why it was questioned: re-fetching 047fe0's live corpus surfaced a case that reason doesn't cover at all: <h1 class="off-screen"> inside <div id="main">, correctly positioned after the repeated nav, still expected failed by ACT. Its own Expectation text requires the heading to be both "included in the accessibility tree" and "visible." A screen-reader-only heading gives sighted keyboard users no equivalent way to locate the start of non-repeated content, which is exactly the gap 047fe0 is checking for.

Decision (2026-08-19): hasHeading() now also requires the heading to carry no CSS-hiding hint (helpers.getVisibilityHintsInfo: off-screen, clipped, opacity:0, zero-size-overflow-hidden). The sibling hasMainLandmark()/hasWorkingAnchorLink() checks are left untouched; cf77f2's own live text doesn't carry the same visibility requirement for a <main> landmark, confirmed by an existing regression test that pins a clipped-but-accessible <main> as still credited.

Status: resolved 2026-08-19, verified directly (tests/engine-checks/manual/bypass-blocks-present.test.js pins the case with the CSS inlined, since the real ACT test case defines it via an external stylesheet jsdom's test fetcher can't load, an env/harness limit of the same class as oj04fd). The corpus checker's own count for 047fe0 stays at 2 (the fixed case can't register as clean through the harness limitation; the other, unrelated mismatch is the genuine, still-open positional-judgment gap).

iframe-name-present exempted every role="none"/"presentation" iframe from needing a name, regardless of focusability, fixed

Decision as it stands (before the fix): the rule's own header comment already claimed this "matches ACT cae760," and the doc's mismatch table filed the one remaining cae760 case entirely under a different, correctly-reasoned divergence (a tabindex="-1" iframe with no role, which ACT considers unreachable and out of scope, but this engine still evaluates). That framing missed a second, distinct mismatch hiding in the same ACT id.

Why it was questioned: re-fetching cae760's live corpus surfaced <iframe title=" " role="none" src="..."> (no tabindex at all) expected failed, which this rule returned notApplicable for. ACT's own reasoning: "because iframe elements are part of sequential focus navigation, the explicit semantic role of none will be ignored, due to Presentational Roles Conflict Resolution." Unlike most elements, <iframe>/<frame> are natively focusable by default, no tabindex needed, so role="none" alone never actually removes one from the tab order, and the "decorative" marking doesn't stick.

Decision (2026-08-19): the role="none"/"presentation" exemption now only applies when the iframe is also out of the tab order (an explicit negative tabindex). helpers.getFocusableInfo has no native-focusability entry for iframe/frame at all (it's an unusual element in that respect: most things need an explicit tabindex or a native-interactive tag), so focusability is computed locally in the rule instead of through the shared helper.

Status: resolved 2026-08-19. cae760 drops from 2 to 1 mismatch; the one remaining case is the pre-existing, correctly-reasoned tabindex="-1"-without-role divergence, left as-is.

aria-valid-attr-value treated an explicitly-empty non-idref value as invalid, and always required aria-errormessage's target to exist, both fixed

Decision as it stands (before the fix): validateAttrValue's empty-value exemption (allowEmpty) only applied to idref/idref-list types; a bare boolean/tristate/number/token-list attribute with no value (aria-checked alone, aria-relevant="") was validated against its type's value set and failed. Existence-checking on single-idref attributes applied uniformly to both aria-activedescendant and aria-errormessage. Confirmed against ACT 6a7281's live corpus (2/23 mismatches).

Why it was questioned: the doc previously filed this under "deliberate scope: our idref-type validation extends beyond ACT's syntax-only check... more useful, not a bug," covering only the aria-errormessage half. But ACT 6a7281's own Applicability text is a blanket rule for every value type: "any WAI-ARIA state or property that is not empty." An empty value (including a bare attribute) is out of scope entirely, not specific to idrefs. And its own Background text names aria-errormessage specifically as a non-required property whose target "may be created in response to an event that may or may not happen," a documented ACT exception, not something our stricter check improves on.

Decision (2026-08-19): validateAttrValue now short-circuits to {valid: true} for any empty value before the type-specific switch, and the idref case exempts aria-errormessage (but not aria-activedescendant, which ACT gives no such carve-out) from existence-checking.

Status: resolved 2026-08-19. 6a7281 runs clean (0/23).

contrast-minimum/contrast-enhanced treated exact foreground/background color matches as a real (1:1) contrast failure, fixed

Decision as it stands (before the fix): the docs described this as an accepted, permanent divergence: "ACT treats text whose color exactly matches its background as invisible; the contrast rules report the 1:1 ratio, since nothing in the markup separates 'invisible on purpose' from 'invisible by accident'."

Why it was questioned: that framing conflates two different questions. Detecting authorial intent (was this meant to be hidden?) is undecidable from markup, and the engine is right not to guess at it. But ACT's own rule doesn't ask that question at all; its stated reasoning for the inapplicable case is purely factual: "this text is not visible because the foreground color is the same as the background color." Whether two fully-resolved, fully-opaque colors are bit-for-bit identical is a plain equality check, deterministic and intent-free, not a judgment call: the same class of fact as any other computability gate this engine already applies (gradients, filters, opacity).

Decision (2026-08-19): added an isSameColorAsBackground gate to the shared text scan (getTextScan in src/core/contrast-helpers.js) that all three contrast rules (contrast-computable, contrast-minimum, contrast-enhanced) already use for applicability. It fires only when both the effective foreground and effective background resolve cleanly to a fully opaque color and those colors are identical; any gradient, image, or partial-opacity background is left untouched and still reaches the existing computability/ratio logic normally. A near-miss (e.g. #fefefe on #ffffff) is unaffected and still fails on ratio, guarded by a regression test.

Status: resolved 2026-08-19. afw4f7 and 09o5cg both drop one mismatch each (7→6, 6→5).

valid-lang's applicability didn't resolve which text actually inherits a given lang attribute, fixed

Decision as it stands (before the fix): valid-lang.js applied to any non-root element carrying a non-empty lang attribute with any non-whitespace text anywhere in its subtree, unconditionally, and validated that attribute's value as a language tag. Confirmed against ACT de46e4's live corpus (3/19 mismatches, exactly the three shapes below).

Why it was questioned: ACT de46e4's applicability is narrower and more precise: it applies only when "there is some text inheriting its programmatic language from the element which is neither empty nor only whitespace." Three concrete gaps: (a) an invalid lang on an element whose entire text content is re-scoped by a nested descendant's own (valid) lang attribute has no text actually governed by the invalid value, so it should pass, not fail; (b) alt text on a descendant counts as governed text too; (c) text that's present but not rendered (display:none) doesn't count as governed text, but aria-hidden and offscreen positioning do not exempt text either, confirmed by ACT's own failed examples for both.

Decision (2026-08-19): rewrote applicability around a hasGovernedText walk: recurses through the element's subtree, stopping at (not recursing into) any descendant that carries its own non-empty lang (that subtree governs itself, not the outer element), and counts a non-empty alt on img/area/input[type=image] the same as a text node. Only actual non-rendering (CSS display:none, the hidden attribute, via the existing targetSet: 'dom' eligibility check) excludes a node; aria-hidden and offscreen positioning are left alone, matching ACT's own failed examples for both.

Status: resolved 2026-08-19. de46e4 runs clean (0/19).

No rule covered role="graphics-symbol"/"graphics-document" on an SVG descendant, only the <svg> root itself, fixed

Decision as it stands (before the fix): svg-text-alternative-present.js's own header comment stated: "Does NOT extend to arbitrary role="graphics-symbol" descendants nested inside an <svg>; this check's scope is the <svg> root only." role-img-text-alternative-present.js was scoped to role="img" only, not graphics-symbol/graphics-document. Confirmed against ACT 7d6734's live corpus (1/10 mismatch, its own failed example: <svg><circle role="graphics-symbol" .../></svg>, a root <svg> with no role at all but a descendant circle carrying the role).

Why it was questioned: ACT 7d6734's applicability is "any SVG element with an explicit role of img, graphics-document, or graphics-symbol," where "SVG element" means any element in the SVG namespace, not just the root <svg> tag; graphics-symbol is specifically meant for descendant shapes. Neither existing rule reached this.

Decision (2026-08-19): role-img-text-alternative-present.js's selector widened from [role="img" i]:not(img) to also match [role="graphics-symbol" i]/[role="graphics-document" i] anywhere in the document; this already covers nested SVG shapes, since the rule was never scoped to a particular tag. Its existing aria-label/aria-labelledby/title checks needed no change; its SVG-first-child-<title> naming check (previously gated to tag === 'svg' only) was widened to any SVG-namespace element via namespaceURI, since SVG-AAM's title-child naming mechanism isn't root-specific either. svg-text-alternative-present.js keeps sole ownership of the <svg> root case (its own header comment updated to point at the sibling rule for descendants). The two rules already had accepted, pre-existing double-coverage on a role="img"/"graphics-document" <svg> root (both fire on the same unnamed element), which this change doesn't newly introduce, only extends consistently to graphics-document alongside the existing img overlap.

Status: resolved 2026-08-19. 7d6734 runs clean (0/10).

img-alt-decorative only considered <img alt="">, missing aria-hidden/role=none images and every svg/canvas case, fixed

Decision as it stands (before the fix): src/checks/manual/img-alt-decorative-manual.js was hard-scoped to a CSS selector matching only img[alt=""]/img[alt^=" "]/img[alt$=" "], i.e. an <img> already marked (or nearly marked) decorative via alt. Confirmed against ACT e88epe's live corpus (4/20 mismatches, all four of its own failed examples: aria-hidden="true" with a non-empty alt, role="none" with a non-empty alt, an unlabeled decorative <svg>, an unlabeled <canvas> drawn via script).

Why it was questioned: ACT e88epe's own applicability, fetched directly, is much broader: "any img, canvas or svg element that is visible and" excluded from the accessibility tree by any mechanism: aria-hidden, role="none"/"presentation", an svg with an implicit/explicit graphics-document role and an empty name, or a canvas with no explicit role and an empty name.

Decision (2026-08-19): rewritten around the direction ACT actually asks for. Instead of a narrow "already-marked-decorative" selector, the rule now selects visible img/canvas/svg elements and asks whether each is excluded from the accessibility tree (isIncludedInAccessibilityTree for aria-hidden/inert; an explicit role="none"/"presentation" or <img alt=""> not overridden by focusability, matching the same conflict-resolution convention already used by svg-text-alternative-present.js; an unlabeled <svg>'s implicit graphics-document role reusing that same file's hasIntent boundary; an unlabeled <canvas> with no explicit role). The noise-reduction concern is handled by ACT's own exception: an element is skipped entirely when any ancestor already has an author-supplied name (aria-label/aria-labelledby/title/<label>), the common real case of an icon-only button already named via aria-label, where whether the icon itself is decorative is moot. Two of ACT's own applicability carve-outs (an <img> mid-load/broken, a fully-transparent <canvas>) aren't decidable from a static scan and are accepted as a documented limitation (docs/LIMITATIONS.md) rather than chased further, a rare, low-cost false positive a reviewer dismisses at a glance.

Status: resolved 2026-08-19. e88epe runs clean (0/20).

label-in-name had no exemption for icon-font glyphs or icon-standing-in characters, fixed

Decision as it stands (before the fix): label-in-name.js did a literal text-containment check between an element's visible text and its accessible name, with no exemption for visible text that is actually rendering as an icon rather than readable text.

Why it was questioned: ACT 2ee8b8's own expectation text includes an explicit carve-out: visible text must be contained in the accessible name "except for characters in the text nodes used to express non-text content." Its own two failed-example fixes: <button aria-label="close">X</button> (a visible "X" glyph standing in for a close icon; ACT: "the 'x' text node is non-text content") and <button aria-label="Find">search</button> styled with an icon font (font-family: 'Material Icons') that remaps the literal word "search" to render as a magnifying-glass glyph.

Decision (2026-08-19): fixed with two narrow heuristics. (a) A curated font-family name list (Material Icons/Symbols, Font Awesome, Ionicons, Glyphicons, IcoMoon, Bootstrap Icons, Feather, ...) catches the icon-font-remap shape, the same curated-list tradeoff as link-name-quality's phrase list. (b) A whole visible label of exactly one character that doesn't even appear inside the accessible name catches the "X" shape, scoped to "doesn't appear in the name at all" specifically so a real word-boundary mismatch (visible "1" against aria-label="1a", where "1" is a substring of the name) still fails outright rather than being swept into the exemption; an existing test guards exactly this distinction. ACT does not itself define an algorithmic test for "non-text content" (confirmed by fetching the rule's own Background/Assumptions text), so both heuristics report cantTell rather than a silent pass, surfacing the case for a human look instead of asserting or hiding a possible defect.

Status: resolved 2026-08-19. 2ee8b8 runs clean (0/13).

The ARIA structure rules only evaluate containers that carry an explicit role, not a bug

Decision as it stands: aria-required-children, aria-prohibited-children and aria-required-parent all key their applicability off getExplicitRole(el); an element with no role="" attribute is never evaluated as a container, however clear its native semantics.

Why it was questioned: an earlier pass, reading ACT bc4a75 from a local checkout of act-rules/act-rules.github.io, took the rule's failed-example 10 to be <ul><div></div><div></div></ul> (an implicit list owning generic children) and concluded our explicit-role-only applicability was a gap.

Decision (2026-08-19): not a bug. Fetching bc4a75's live page directly (act-rules.github.io) shows its Applicability text reads "has a WAI-ARIA 1.1 explicit semantic role with required owned elements", explicit is load-bearing, and its own Inapplicable Example 2 is exactly <ul><li>Item 1</li></ul>, plain native markup with no role anywhere. ACT itself excludes bare native containers from this rule; the local-checkout reading that prompted this entry didn't match the current published rule. getExplicitRole-only applicability is correct as written and needs no change.

Status: resolved 2026-08-19, no code change; confirmed against the live ACT rule text.

aria-prohibited-children gated group/rowgroup transparency behind the container's own required-owned set, a real bug, now fixed

Decision as it stands (before the fix): a role="group"/role="rowgroup" owned child was only treated as a transparent wrapper (recursed through) when the container's own required-owned-roles set happened to include group/rowgroup, true for menu/menubar/tree, false for list, listbox, table, radiogroup, tablist.

Why it was questioned: running the live bc4a75 corpus surfaced a passed example this engine failed: <div role="list"><span role="listitem">Item 1</span><div role="group"><span role="listitem">Item 2</span><span role="listitem">Item 3</span></div></div>. list's required-owned set is listitem only, so the group wrapper was treated as a real, non-transparent owned entry with role group, not in the required set, and flagged. ACT's failed example 6 (role="list" wrapping a role="group" that owns role="tab" children, expected to fail) confirms the same: group is transparent under list regardless of list's own required-owned set, so what determines the outcome is the group's own children, not the group role itself.

Decision: group/rowgroup are universally transparent intermediary containers for owned-element matching, for any container role, not conditional on the container's own required-owned set naming group/rowgroup as an acceptable leaf role. Fixed in collectOwnedRoles (src/checks/automatic/aria-prohibited-children.js).

Status: resolved 2026-08-19. bc4a75 runs clean (0/19 mismatches).

Page-wide duplicate-id checking was skipped once, now built, version-scoped

Decision as it stands: src/checks/automatic/duplicate-id-aria.js only flags a duplicate id when it's referenced by an ARIA ID-reference attribute. Its header comment: "Scoped to ids referenced by ARIA, not the broader/deprecated page-wide duplicate-id check (see ROADMAP.md's 'Skip' list)." That ROADMAP.md no longer exists in the repo (not found in the working tree or as a tracked file in git log, likely a local planning doc that was never committed), so the original reasoning behind "skip" isn't recoverable verbatim, only the pointer to it.

Why it's being questioned: while mining ACT's gap list (per the user's request to find gaps worth turning into new rules), 3ea0c8 "Id attribute value is unique" is exactly this broader page-wide check, and it's detectable with a simple, deterministic document-wide scan. Checked ACT's own SC mapping for it: 3ea0c8 maps to WCAG 4.1.1 Parsing, which the Working Group formally removed in WCAG 2.2 (browsers/AT no longer depend on strict-parsing conformance the way they did when that SC was written), and axe-core deprecated its own equivalent broad duplicate-id check around the same time, for the same reason. So the original "skip" call was well-founded for WCAG 2.2 conformance scoring specifically.

That said, duplicate IDs are still a real, practical bug independent of which SC currently covers them: they break <label for> association, fragment navigation, and any getElementById/querySelector('#...') call, not just ARIA references. This engine already supports WCAG-version-scoped tagging (wcag2a/wcag21a/wcag22aa-style tags, see docs/ENGINE_OPTIONS.md's WCAG-version filtering). A page-wide duplicate-id rule could be added and tagged as WCAG 2.0/2.1-only (wcag411-style, excluded from WCAG 2.2 tag sets) rather than either fully skipped or wrongly counted against 2.2 conformance, the two options the original either/or "skip" decision didn't have room for.

Decision (2026-08-19): build it, version-scoped. The new duplicate-id rule maps to SC 4.1.1 and carries wcag2a (its 2.0/2.1 origin) plus a new wcag22-removed tag; a consumer targeting WCAG 2.2 drops it with excludeTags: ['wcag22-removed'], one targeting 2.0 or 2.1 keeps a real 4.1.1 result. That is the third option the original either/or "skip" call did not have room for: the defect is real regardless of which SC covers it (<label for>, fragment navigation and getElementById all resolve to the first match), while the conformance arithmetic stays honest for every version. src/coverage/wcag-version-map.js gained WCAG22_REMOVED_SCS/removedInVersion so the removal is recorded next to the additions rather than living only in a rule comment.

Status: resolved 2026-08-19. duplicate-id ships, clean against all 10 of ACT 3ea0c8's examples.

<label for>/wrapping association is applied to elements that aren't natively labelable, contradicting ACT

Decision as it stands: the shared accessible-name helper (getAccessibleNameInfo in src/core/dom-helpers.js, ~line 2984) falls back to a label[for]/wrapping-<label> lookup by element id for any element, not just genuinely labelable native ones (input/textarea/select/button/output/meter/progress). Its own comment describes this as intentional: "fallback for elements where .labels isn't natively available, e.g. a non-native-labelable element like <div role="button" id="x"> still explicitly pointed at by <label for="x">." The same pattern is duplicated in textbox-name-present.js, combobox-name-present.js, listbox-name-present.js, searchbox-name-present.js, slider-name-present.js, and spinbutton-name-present.js.

Why it's being questioned: ACT e086e5's own test corpus fails a <label>first name<div role="textbox"></div></label> (and the label[for] equivalent). A <div role="textbox"> isn't a native HTML label target, and per HTML, <label> only creates a real accessible-name association with labelable elements. textbox-name-present.js's own header comment even states the opposite of what the shared helper does: role="textbox" is "name-from-author-only... must NOT fall back to subtree content." The intent was clearly to be strict here, but the <label> fallback undermines it.

Decision (2026-08-19): keep the leniency. The question was whether this engine follows the spec or follows what assistive technology actually does, and the answer here is what users experience: where a screen reader announces a <label for> pointed at a non-labelable ARIA widget, an engine that calls that name absent would report a missing name the user can hear perfectly well, a false positive, and the worst kind, since it sends an author to "fix" working markup. Reporting a name that some AT ignores is the safer error: it under-reports a real problem rather than inventing one, and the widget's own naming rules (aria-label/aria-labelledby) still apply on top.

Two consequences, both accepted: the shared helper and its six rule-local copies stay as they are, and ACT e086e5's two <label>-on-role="textbox" failed examples stay permanent mismatches, reclassified in docs/ACT_RULE_MAPPING.md from an open question to a deliberate divergence.

Status: resolved 2026-08-19, no code change; behaviour confirmed as intended.

aria-allowed-attr only checks elements with an explicit role attribute, the entry was written from a stale comment

Decision as it stood: src/checks/automatic/aria-allowed-attr.js's header comment said the rule was scoped to elements carrying an explicit role="...", and this entry took it at its word.

What was actually true: the comment was out of date when the entry was written. An implicit-role path had already landed on 2026-08-13 (cdf9a13), with a generated IMPLICIT_ROLE_BY_ELEMENT table gated on elements whose role is the same in every context, and the rule had been judging <p aria-level="2"> and friends ever since. The entry described the documentation, not the code, a reminder that a header comment is evidence of intent, not of behaviour, and that the check is one runa11yCoreOnHtml call away.

What the real remaining gap was: elements HTML-AAM maps to no role at all. ACT 5c01ea's failed example 2 is <audio controls aria-orientation="horizontal">: audio has no role, so no role-specific attribute is supported on it, and the rule skipped it because the implicit-role lookup came back empty, indistinguishable, in the old code, from "a role this table does not model."

Decision (2026-08-19): separate the two. A generated ROLELESS_ELEMENTS set (audio, video) makes "no role in any context" an answer rather than a shrug, and every non-global ARIA attribute on one of those is reported. div/span joined the context-free table as generic, whose supported set is empty, so <div aria-expanded="true"> is now reported too; the attribute announces nothing there, a real defect rather than a spec technicality. Context-dependent elements (<a>, <section>, <td>, ...) are still skipped rather than guessed at; that restraint is what the second entry above is about.

Status: resolved 2026-08-19. 5c01ea now runs clean against all 17 of ACT's examples, and the rule's header comment describes what it does.

aria-required-children uses "at least one acceptable owned role," where ACT requires every owned role to be acceptable

Decision as it stood: aria-required-children is satisfied by finding any single matching descendant, and its header comment called that a recall-over-precision trade-off. This entry read ACT bc4a75's exclusive expectation against it and concluded the engine would miss any container mixing valid and invalid owned children, a role="list" holding one real listitem and a stray role="button".

What was actually true: the exclusive check exists, in aria-prohibited-children. This repo splits ACT's single rule into two atomic decisions: "does a required child exist" and "is every owned child allowed," and the second one already walks the owned graph exclusively, with group/rowgroup transparency and boundary handling. ACT's failed example 6, the nested group owning treeitems that this entry quoted in full, fails today; so does the mixed list. The entry compared ACT's rule against one half of the pair.

Decision (2026-08-19): no algorithm rewrite. The defect was in the mapping, which pointed bc4a75 at aria-required-children alone, so the corpus run measured half the coverage and reported the other half as missing. bc4a75 is now a family match over both rules, and aria-required-children's header says which half it owns, so the next reader does not repeat the inference.

Status: resolved 2026-08-19. bc4a75 went from 4 mismatches to 1, the remainder being the implicit-container applicability entry below.

NATIVE_CONTAINMENT_ROLE_BY_ELEMENT gave several native tags an unconditional implicit role, ignoring HTML-AAM's context requirement

Decision as it stood: getContainmentRole mapped li → listitem, option → option, tr → row, td → cell, th → columnheader and the row groups unconditionally, whatever contained them.

Why it was wrong: HTML-AAM makes those roles conditional. An <li> is a listitem only as a child of <ul>, <ol> or <menu>; an <option> only inside select/datalist/optgroup; the table family only inside a real table. ACT bc4a75 tests it directly: <div role="list"><li>Item 1</li><span role="link">Item 2</span></div> must fail, because with the <li> carrying no role the list owns nothing valid at all, and the engine passed it.

Decision (2026-08-19): fixed. A NATIVE_CONTAINMENT_CONTEXT table records the containing tags each conditional role needs, split between HTML-AAM's "child of" conditions (li, option, the row groups) and its "descendant of a table" ones (tr, td, th), which sit inside a rowgroup in most real tables. The common CSS-reset shape <ul role="list"><li>…</li></ul> is untouched, since the <li>'s parent really is a <ul>.

One existing test changed meaning with it: a bare <option> under role="listbox", outside any <select>, used to count as the listbox's owned child. It no longer carries a role, so it is transparent and a focusable element inside it becomes the listbox's own roleless owned entry. That is the same conditional ACT applies to <li>, so applying it to <option> too is the consistent reading; the alternative would have been to accept role="listbox" as native context for <option> while ACT explicitly refuses role="list" as context for <li>.

Status: resolved 2026-08-19.

contrast-computable never treated text-shadow as a blocker, though ACT's own examples rely on one to rescue otherwise-failing contrast, fixed

Decision as it stood: getComputabilityBlocker walked ancestors checking mix-blend-mode, filter/backdrop-filter, background-image/gradient, and ancestor opacity, but never looked at text-shadow on the text element itself.

Why it was questioned: ACT afw4f7's own passed example is color: #AAA text over a #EEE-ish background with a strong contrasting text-shadow outline, text that would normally fail the ratio test but passes because the shadow supplies enough perceptible contrast around each glyph. This engine has no glyph-rendering model to compute how a shadow affects the ratio, so asserting a confident fail there (as it previously did) contradicts a real browser's rendering; the correct answer is the same "defer to manual review" shape already used for every other computability blocker.

Decision (2026-08-19): added a text-shadow check to getComputabilityBlocker, scoped to the text element itself (a foreground property already resolved by inheritance, unlike the ancestor-walked background properties). A declared shadow with non-zero alpha now reports cantTell with reasonCode: 'TEXT_SHADOW' instead of asserting pass/fail. Implementing this surfaced a confirmed jsdom (29.1.1) bug: reading computed text-shadow a second time on the same element silently returns a different, wrong value, worked around by reading it exactly once per element and caching the result (see docs/LIMITATIONS.md).

Status: resolved 2026-08-19. afw4f7 drops from 5 to 4 mismatches, 09o5cg from 5 to 4 (the remaining mismatches on both are the unrelated gradient-background and shadow-DOM-via-script cases, see docs/ACT_RULE_MAPPING.md).

Decision as it stood: the mapping doc (docs/ACT_RULE_MAPPING.md) filed ACT fd3a94/b20e66's remaining mismatch as a genuine structural limit: the rule's a[href]-only selector "cannot cover a role="link" element whose target lives inside a JS string, not markup," filed in the same family as this engine's documented "dynamic/post-interaction state" limitation (docs/LIMITATIONS.md), implying nothing could be done short of executing script.

Why it was questioned: a re-audit against the live ACT rule pages found the actual failing snippets are <span role="link" tabindex="0" onclick="location='/about/contact.html'">; the destination is a literal string sitting in the onclick attribute's value, readable via el.getAttribute('onclick') without executing anything. It only looks script-dependent because a real browser resolves it by running the handler; the string itself is already static markup. link-name-quality had already independently widened its own selector to a[href], area[href], [role="link"] for the same "any semantic link" applicability reasoning (see the "Real rule bugs found and fixed" list above); this rule just hadn't received the same treatment.

Decision (2026-08-19): widened the applicability selector to a[href], [role="link"], and added a regex fallback (resolveOnclickLocation) that extracts a destination from a location='...'/location.href='...'/location.assign('...')/location.replace('...') pattern in the element's onclick attribute when it has no real href. This rule is cantTell-capped (never asserts fail), so an onclick shape the regex doesn't recognize simply isn't resolved, a recall cost, not a false-fail risk.

Status: resolved 2026-08-19. fd3a94 and b20e66 both run clean against the live ACT corpus (0/19, 0/21).

iframe-name-present's focusability exemption was filed as a deliberate broader-than-ACT scope choice, actually an implementation accident, now fixed

Decision as it stood: the mapping doc filed the remaining cae760 mismatch as deliberate: "iframe-name-present doesn't exempt a tabindex="-1" iframe the way ACT's focus-reachability precondition does; arguably more useful for AT rotor/frame-list navigation, not just Tab order," framed as an intentional, considered choice to go beyond ACT's own scope.

Why it was questioned: re-reading ACT cae760's own Applicability text directly: "This rule applies to iframe elements that are included in the accessibility tree and that can be accessed by sequential focus navigation," two independent, unconditional AND conditions, not a role-scoped exception. The codebase's own isFrameFocusable() exemption was written nested inside the role="none"/"presentation" branch (added specifically to fix the earlier presentational-conflict-resolution case), so it only ever fired for a decorative-marked iframe; a plain <iframe tabindex="-1"> with no role at all, which is cae760's own passed/inapplicable example, fell straight through and still got flagged. The "arguably more useful for AT rotor navigation" rationale reads like a justification invented after the gap was noticed, not a decision made on its own merits before shipping.

Decision (2026-08-19): the focusability check now gates applicability directly (if (!isFrameFocusable(el)) continue;), independent of role, matching cae760's own unconditional AND. A focusable role="none" iframe still needs a name (Presentational Roles Conflict Resolution correctly keeps applying there); a non-focusable iframe of any role, or no role, is now out of scope, matching the live rule exactly.

Status: resolved 2026-08-19. cae760 runs clean against the live ACT corpus (0/10).

aria-role-name-present failed five roles WAI-ARIA never required a name for, fixed

Decision as it stood: the rule carried a hand-written allowlist of ten roles: scrollbar, toolbar, tablist, radiogroup, tree, grid, menu, menubar, meter, progressbar, and reported any unnamed one as a serious, high-confidence WCAG 4.1.2 Level A failure. The rule's header comment defended the name computation at length (name-from-author-only, so descendant text is never accepted, or a labelled child would pass its unnamed container) but said nothing about how the list itself was chosen, beyond that it was "a frozen allowlist rather than every role WAI-ARIA lets an author name."

Why it was questioned: "lets an author name" is the wrong predicate. WAI-ARIA records two separate characteristics per role, Name From (where a name may come from) and Accessible Name Required (whether one must exist), and the list collapsed them. Checked against aria-query, the same source CHANGELOG.md records this repo using to reconcile aria-allowed-attr against ARIA 1.2, five of the ten roles are nameRequired: false: tablist, toolbar, menu, menubar and scrollbar. For those, no normative route to a 4.1.2 failure exists. WCAG 4.1.2 governs user interface components; in a tab widget the operable components are the tabs, which name themselves from their own contents, and the tablist is a container that manages them. The contrast with radiogroup shows what the spec is encoding: a radiogroup's name usually carries the question itself ("Shipping method"), without which the options are semantically stranded, which is why ARIA marks that one required. Naming a tablist is a WAI-ARIA Authoring Practices recommendation, and the project's own policy model is explicit that advisory findings must not produce fail (docs/POLICY.md, and the design-doc quote in landmark-banner-is-top-level-manual.js). The rule also had no ACT counterpart (docs/ACT_RULE_MAPPING.md files it under "Extra coverage beyond ACT"), so nothing in scripts/act-testcase-check.js ever validated the applicability set. The practical cost was a mainstream, perfectly usable pattern (one tab widget under a visible heading, no aria-label on the tablist) reported as a serious Level A failure at high confidence.

Options weighed: a cantTell tier for the not-required roles was designed in full before being dropped. The engine supports it (resolveTieredOutcome in src/core/dom-helpers.js exists precisely for a fail/cantTell split within one automatic rule), but the value did not survive scrutiny. Unconditionally, it flags every unnamed tablist on every page, and the reviewer answers "fine" nearly every time; that is not review, it is the same false positive at a lower severity. Conditioned on two or more indistinguishable same-role containers (where the name actually does work), it needs a page-level count that becomes ambiguous under root-scoped scans. Either way it costs i18n keys in four locales and, per docs/WCAG_CONFORMANCE.md, flips the wcag-4.1.2-name-role-value composite from pass to cantTell for something the spec does not require. The asymmetry decided it: adding an advisory rule later is cheap, and un-shipping a noisy cantTell after integrators have built baselines around it is not.

Decision (2026-08-21): the five name-not-required roles are simply out of applicability, not passing, not reviewed, out of scope, which is the accurate statement. The rule now evaluates grid, meter, progressbar, radiogroup and tree, and its 4.1.2 / Level A / serious / high metadata is finally true of every finding it emits. The set is generated from aria-query by scripts/generate-aria-tables.js into a marked block, with the exclusions and their reasons recorded beside it, and a test in the rule's own suite re-derives it from aria-query so a future hand-edit back to a hand-picked allowlist fails CI rather than shipping. meter and progressbar were kept despite having dedicated rules: those map to SC 1.1.1, so this rule is what gives the two roles any 4.1.2 coverage at all; dropping them as "redundant" would have punched a quiet hole in the rollup. (That the dedicated rules map a naming failure to 1.1.1 rather than 4.1.2 looks wrong on its own terms, and is worth a separate look.)

Status: resolved 2026-08-21. The roles ARIA actually requires a name for still fail; the ones it does not are no longer reported. The converse gap this exposed is tracked in Open above.

aria-prohibited-children attributed an item's own content to the container, six levels up, fixed

Decision as it stood: collectOwnedRoles treated any child with no containment role as fully transparent and recursed through it, up to MAX_DEPTH = 40, stopping only at the first element carrying a real role. "Owned child of the container" therefore meant "the first role-bearing element found down each branch, at any depth."

Why it was questioned: a real Angular Material scan reported role="separator" on a mat-divider as a prohibited child of an enclosing role="radiogroup". The divider sits at mat-radio-group > div > div > avq-card > div > avq-card-content > div > mat-divider, six levels down, inside one card's body, dividing two columns of that card's content. In the accessibility tree its parent is a generic container inside the card; it is not a child of the radiogroup by any reading. The transparency that produced this is not itself wrong, it is forced, because the card wrapper carries no role and the radio it holds is buried at avq-card-header > div > mat-radio-button > div > div > input[type=radio]; a walk that refuses to descend reports every card-based radio group as owning no radios at all. The defect was that the leniency ran in both directions: descending to find the item also swept up everything else in the item's subtree and judged it against the container. Reproduced across the container roles: a scroll button beside the tabs in a roleless tab-strip wrapper (tablist), a role="img" icon beside the option in a row wrapper (listbox), a role="status" in a body wrapper (grid), a role="tooltip" beside a treeitem (tree).

Why "only direct children" was not the fix: under a strict accessibility-tree reading, the generic wrapper is itself an owned child whose role (generic) is not in the required set, so a literal direct-children rule fails the same markup, just blaming a different element, and also fails the very common <div role="list"><div><div role="listitem">. Roleless elements are not removed from the tree the way role="none"/"presentation" elements are; they are exposed as generic nodes. The engine's descent is a leniency on top of that, and the fix had to preserve it.

Decision (2026-08-21): when the walk enters a roleless wrapper, it now checks whether that wrapper's subtree yields any role from the container's required set. If it does, the wrapper is an item wrapper: only the items are collected, and the rest of its subtree is the item's own content, outside this rule's question. If it does not, the wrapper is interposed content and everything found inside it is still reported. Scoped to roleless wrappers only: role="none"/"presentation" keeps its existing behaviour, because a presentational element genuinely is removed from the accessibility tree with its children promoted to the container, and group/rowgroup keep the unconditional transparency ACT bc4a75 confirmed. A genuinely stray direct child of the container is still reported, which is the rule's real value and is covered by regression tests alongside each fixed shape.

Cost of the suppression, measured: the first write-up of this entry called the suppression a straight recall trade: "a stray role="button" next to a role="listitem" inside one wrapper is no longer reported." Measuring it showed that framing was too pessimistic. Placing every candidate stray role beside a valid item inside one roleless wrapper, across list/radiogroup/tablist/listbox/tree, splits cleanly in two. Every role that could plausibly be a misplaced item (option, tab, treeitem, menuitem, row, gridcell, listitem) is still reported, by aria-required-parent: those roles carry a Required Context Role in ARIA, a roleless div does not satisfy it, and that rule works from the child's side, untouched by this change. What actually falls through is only roles ARIA places no context requirement on at all (button, separator, status, tooltip, region), and for those there is no normative basis to call them misplaced in the first place; they are content, which is exactly what the reported mat-divider was. So the suppression costs no finding that any rule could justify making. A role="group" wrapper is not roleless and stays fully strict: list > group > [listitem, option] still fails here as well as in aria-required-parent.

Two refinements considered and rejected: reporting item-ish strays inside item wrappers anyway would duplicate aria-required-parent exactly, same defect, two rule IDs, two occurrences per element, against this repo's one-rule-one-decision principle. Flagging strays that are direct siblings of the item would catch the listitem + button shape, but also <span role="listitem">Invoice</span><span role="img" aria-label="paid">, a row with a status icon beside its item: the same pattern class this entry fixes, re-broken on a heuristic with no spec text behind it.

Note on ACT: docs/ACT_RULE_MAPPING.md lists bc4a75 as running clean before this change, so its published examples never exercised the roleless-wrapper shape; passing that corpus was not evidence the behaviour was right. Re-running scripts/act-testcase-check.js to confirm the corpus is still clean afterwards needs network access to the ACT rule pages, which this environment's egress proxy blocks; it should be re-run wherever that is available.

Status: resolved 2026-08-21. The reported false positive and four more of the same shape across tablist, listbox, grid and tree now pass; the stray-direct-child cases still fail. The related strictness question is tracked in Open above.

aria-prohibited-children treated "required owned elements" as the exhaustive list of permitted children, fixed

Decision as it stood: the rule's header stated it outright: "the 'allowed owned roles' set is exactly REQUIRED_OWNED_ROLES, not a separately authored, broader list: an owned element is allowed only if its role is literally in the container's required set." A role="menu" could own menuitem, menuitemcheckbox, menuitemradio and group, and nothing else.

Why it was questioned: WAI-ARIA's "Required Owned Elements" answers what a container MUST contain. It is not a permitted-children list, and using it as one produced two false positives on markup the spec itself describes. A role="separator" between menu items was reported, though ARIA defines that role as "a divider that separates and distinguishes sections of content or groups of menuitems" and the Authoring Practices menu and menubar patterns use separators throughout. Worse, a role="caption" on a role="table"/role="grid" was reported, while the engine's own REQUIRED_CONTEXT_ROLE table says a caption must be inside a figure, grid or table; the two tables contradicted each other, and this rule lost.

Decision (2026-08-21): a second table, ALLOWED_EXTRA_OWNED_ROLES, carries the difference between "must contain" and "may contain," and the verdict is taken against required ∪ allowed. It is small on purpose, and an entry needs one of two sources: ARIA gives the child role a Required Context Role naming this container (caption in table/grid, mechanically checkable, since prohibiting a child ARIA says belongs there is self-contradiction), or the child role's own spec definition places it there (separator in menu/menubar, separator has no Required Context Role at all, so no derivation can express this and it is listed by hand). Generated by scripts/generate-aria-tables.js, which records the source for each entry and rejects one that satisfies neither: it throws on a role already required by that container, and on a role whose declared context roles do not include it.

Not added, each rejected by that validator or by HTML: treegrid: caption, caption's context is figure/grid/table, and although treegrid subclasses grid, extending it would be this repo's judgement rather than ARIA's. rowgroup: rowheader, aria-query lists it, but HTML has no counterpart (a <th> must live in a <tr>) and the spec's own rowheader context is row; per generate-aria-tables.js's header, the spec wins over the package, so it waits until it can be checked against the spec directly. list: separator, <ul>/<ol> admit only <li> plus script-supporting elements, and no spec text extends the menuitem carve-out to lists.

Interaction with the item-wrapper fix above: the two sets are used for different questions, on purpose. Only a required role makes a roleless wrapper an item wrapper, so a wrapper holding nothing but a separator is still interposed content and is still reported under a container that prohibits separators. The allowed set decides only the final verdict.

Status: resolved 2026-08-21. Separators in menus/menubars and captions on tables/grids pass; separators under list/listbox/tablist, captions under treegrid, and any stray role with no source behind it still fail. The reported allowed-roles list in the failure message now names the full allowed set rather than only the required roles.

A dangling aria-controls was a fail, in a rule that cannot see the DOM the reference is about

Decision as it stood: aria-valid-attr-value treats every ID-reference attribute the same way. An idref-list whose tokens all fail to resolve is an invalid value, hence a fail under SC 4.1.2. Exactly one attribute already had a carve-out: aria-errormessage, on the strength of ACT 6a7281's own Background text, which names it as a non-required property whose target "may be created in response to an event that may or may not happen."

Why it was questioned: that carve-out's reasoning covers aria-controls at least as well. A disclosure button, a combobox, a menu button and a tab all name content the widget builds when it opens, so the reference is correct and the element genuinely is not in the DOM yet. A static scan that looks for it and does not find it has not established a defect; it has established that it looked at the wrong moment. axe-core reached the same conclusion independently: it never reports a violation for a missing aria-controls target, passing when the element is collapsed and returning incomplete otherwise.

Decision (2026-08-27): aria-controls no longer fails on an unresolved target. When the element carries aria-expanded="false" or aria-selected="false" the absence is exactly what that state means, so the rule passes outright; otherwise it reports cantTell for human review, with reason code idref-controls-not-found. Every other idref/idref-list attribute keeps its fail, since a dangling aria-labelledby or aria-owns names content that was supposed to be there already and no state excuses it. The rule now reports two tiers through helpers.resolveTieredOutcome, so a real invalid value elsewhere on the page still gates as fail and carries the cantTell occurrences along rather than dropping them.

Status: resolved 2026-08-27.

SC 4.1.1 Parsing could still fail a WCAG 2.2 run, unless the caller remembered a tag

Decision as it stood: duplicate-id maps to SC 4.1.1 and carries wcag22-removed (see the entry above). The tag was inert engine-side: it existed for consumers to pass to excludeTags themselves, and docs/ENGINE_OPTIONS.md told them to.

Why it was questioned: the package describes itself as a WCAG 2.2 engine, and a plain run, with no tags and no options, still reported a fail against a criterion WCAG 2.2 does not contain. The correct behaviour was reachable but opt-in, which is backwards: the default should be right and the deviation should be the thing you ask for. Leaving it to the caller also meant the honesty the tag was created to protect, that "the conformance arithmetic stays honest for every version", only held for callers who knew the tag existed.

Decision (2026-08-27): the engine resolves a target WCAG version per run, from engineOptions.wcagVersion, else whatever the caller's own version-origin tags imply, else 2.2, and reports it as engine.wcagVersion. Under a 2.2 target a wcag22-removed rule cannot report fail: it runs, keeps every occurrence, and its outcome is coerced to cantTell with a wcagVersionScope field naming the removed criterion. Coercing rather than excluding was the deliberate choice, since a duplicate id still breaks <label for>, fragment navigation and getElementById, so dropping the rule from a 2.2 run would hide a real defect, and this engine's whole premise is telling you what it cannot tell you. excludeTags: ['wcag22-removed'] still removes it entirely for anyone who wants that. The coercion deliberately does not go through error, the channel the two existing coercions use, because consumers read a non-empty error as "this rule threw" and nothing went wrong here.

Status: resolved 2026-08-27.

area-alt-present treated <area alt=""> as satisfying its check, borrowing <img>'s decorative marker for an element that can't be decorative

Decision as it stands (before the change): an <area> with a present-but-empty alt passed area-alt-present outright, the same treatment <img alt=""> gets. A separate manual rule, area-alt-decorative, then asked a human to confirm the empty-alt area really was decorative.

Why it was questioned: <img alt=""> has a real decorative use: the image can carry zero information while everything else on the page still works. An <area> has no equivalent — it exists in a used <map> only to be a hyperlink hotspot (that's the whole point of the usemap/href mechanism), so its HTML-AAM role is link and an empty alt just means an unnamed link, not a decorative one. There is no redundant sibling content standing in for it the way there is for a decorative image; the geometry itself is invisible.

Decision (2026-09-14): area-alt-present now fails an <area> with alt="" and no other accessible name (aria-label/aria-labelledby/title still count, same fallback order as before), with its own summary/hint distinct from the missing-alt case. area-alt-decorative is retired rather than kept dormant, since "is this decorative" never had a legitimate yes for this element — see [Removed] in CHANGELOG.md. area-alt-quality (non-empty alt, asking whether the text is accurate) is unaffected and stays a legitimate manual question.

Accepted cost: scripts/data/finding-ids.json's rule-id inventory drops from 133 to 132. A stored baseline holding a cantTell finding against area-alt-decorative finds the id gone, not resolved.

Status: resolved 2026-09-14.

hasBlockingInert ignored inert on an <area> itself or on its <map>, undocumented since the project's first commit

Decision as it stands (before the change): hasBlockingInert in dom-helpers.js carried an <area>-specific exception: inert on the area itself, or on its closest <map>, was ignored; only an inert ancestor outside that chain excluded the area. Present since the very first commit (4961752), pinned by a pointed unit test, with no rationale recorded in the commit, this file, or RULE_AUTHORING.md.

Why it was questioned: the shape reads like a generalization of a different, correct rule — that aria-hidden on a focusable element does not remove it from eligibility, since a real user can still Tab onto it (the exact pattern aria-hidden-focus exists to catch). inert is not aria-hidden: the HTML spec has it remove focusability directly, for every element type, with no image-map carve-out in the algorithm, so the "still really reachable" premise that justifies the aria-hidden exception does not hold for inert. More directly: aria-hidden-focus.js already implements this exact question independently (hasInertAncestor, walking the element itself and every ancestor) with no <area>/<map> exception at all — two pieces of code in the same repo disagreed on the identical input, <area inert>.

Decision (2026-09-14): the exception is removed. inert on the <area>, its <map>, or any ancestor now excludes the area uniformly, matching every other element's default handling and matching aria-hidden-focus.js's own independent check. A genuinely inert <area> is notApplicable rather than a reported failure.

Correction (2026-09-14, same day): wrong. This reasoned from spec text without checking what a real browser actually does, which is exactly the thing the Method section above says to verify. Tested via real keyboard Tab navigation in Chromium and Firefox: an <area inert> and an <area> inside an inert <map> both stay in the tab order. <area>/<map> generate no box, so a browser's image-map hit-testing sits outside the pipeline inert operates on — the original, undocumented exception was empirically correct despite having no stated reason, and the aria-hidden-focus.js comparison that seemed to settle the question was comparing the wrong thing: that rule never evaluates <area> in practice, so it never had occasion to get this right or wrong. Re-reverted; see the entry below for the full, verified picture, which also turned up two related gaps this entry didn't touch.

Status: superseded 2026-09-14 by the entry below.

isPlatformFocusable treated every <area> in a used map as focusable, href or not, unlike its own <a> branch three lines above it, fixed

Decision as it stands (before the change): isPlatformFocusable's tag === 'area' branch (dom-helpers.js) treated an <area> as focusable purely for belonging to a <map> a rendered <img usemap> references — no href check. The tag === 'a' branch immediately above it requires a non-empty href before returning focusable. area-alt-present and area-alt-quality inherited this: both evaluated any <area> in a used map, href or not, at their own applicability gate (each rule's local getReferencingImgForArea/used-map lookup, not isPlatformFocusable itself for the baseline case).

Why it was questioned: per the HTML spec, an <area> with no href does not represent a hyperlink at all — "represents merely a region of the map that has no associated action" — so it is not focusable, not in the accessibility tree as a link, and has no accessible-name requirement to fail. Run against scripts/other-engine-corpus-check.js (built 2026-09-14) scoped to a third-party scanner's own area-alt rule, area-alt-present's fixture agreed on exactly one of its seven failing cases — the one case that happened to carry href (area_case_16). The other six (missing alt, empty alt, aria-hidden-but-focusable, role="presentation", aria-disabled, a dangling aria-labelledby) all omitted href entirely and the other tool didn't flag any of them. area-alt-present has no ACT counterpart (docs/ACT_RULE_MAPPING.md's "Extra coverage beyond ACT" list), so this never surfaced through that comparison either — first time this rule had been checked against any outside ground truth. The <area> branch's own comment ("Engine policy: treat <area> as focusable when it's part of a used image map") dated to the project's first commit, with nothing explaining why href was left out unlike the <a> branch beside it.

Decision (2026-09-14): isPlatformFocusable's <area> branch now requires a non-empty href before doing the used-map lookup, matching the <a> branch. area-alt-present.js and area-alt-quality-manual.js each gained the same href check at their own applicability gate, right after the existing used-map lookup, since that gate — not isPlatformFocusable — is what actually governs a case's baseline applicability; isPlatformFocusable's branch only mattered for the aria-hidden-override and role="presentation"-exclusion paths. Both rules' fixtures had href retrofitted onto every case testing naming/eligibility logic (all but one case in each fixture previously omitted it), and area_case_16 in area-alt-present's fixture — previously "href present, still fails" — was repurposed to cover the newly-distinct branch, an <area> with no href in a used map, since its original point had become redundant with area_case_01 once every other case also gained href.

Status: resolved 2026-09-14.

<area>/<map> eligibility was checked against the wrong ground truth: spec text and isAccTreeEligible's own layered design, not what a real browser does with a non-rendered element pair

Decision as it stands (before the change): three related exclusions, none verified against a real browser. hidden/display:none/inert on the <area> itself, or display:none/inert on its <map>, all excluded the area (the entry above covers inert specifically; hidden and display:none on the <map> were separate, pre-existing gaps with no carve-out at all, not something this session introduced). Separately, area-alt-present.js/area-alt-quality-manual.js gated the area's applicability on isAccTreeEligible(img, ctx) for the referencing <img>, which folds in aria-hidden.

Why it was questioned: the entry above records getting the inert question wrong once already by reasoning from spec text instead of testing it. Doing the same check for hidden/display:none this time, and testing all of it directly: loaded each scenario in a real page and drove actual keyboard Tab navigation in Chromium and Firefox (WebKit's headless tab handling looked broken in the same harness, so not counted). Result: hidden on the <area>, and display:none or inert on the <map>, all leave the area fully reachable — same non-rendered-element reasoning as inert, just never applied to the other two mechanisms. Separately, aria-hidden on the referencing <img> also leaves the area reachable, for a different reason: <area> is not a DOM descendant of <img>, only linked by the usemap IDREF, so aria-hidden has no ancestor relationship to propagate along, and the img is still rendered regardless of its ARIA state. Gating the area's applicability on the img's full isAccTreeEligible — which folds in aria-hidden — conflated two different questions: is the img actually rendered (genuinely relevant, since that's what the hotspot geometry depends on) versus is the img exposed to the accessibility tree (irrelevant here).

Decision (2026-09-14): hasBlockingInert's <area> carve-out is restored (see entry above) with the verified reason recorded this time. The structural hidden-attribute check and the CSS display:none ancestor check in isAccTreeEligible both gained the same carve-out, extended to a <map> ancestor as well as the area itself (visibility:hidden needed no change — it was already skipped for <area> nodes and already gave the right answer). area-alt-present.js/area-alt-quality-manual.js's referencing-<img> gate switched from isAccTreeEligible to isDomVisibleEligible ({ visibilityMode: 'styleOnly', disableGeometry: true }), the same DOM-only helper aria-hidden-focus.js already uses for this exact distinction — hidden/display:none/visibility on the img still excludes the area; aria-hidden on the img no longer does. Both fixtures gained the corrected titles/spans and cases, and a note in the hidden_css/inertness section headings that these mechanisms are ignored on the area/map itself but still block on a genuine outside ancestor.

Accepted cost: area-alt-present's fixture goes from 6 failing cases to 11; area-alt-quality's goes from 1 applicable case to 4. Both increases are real markup this rule should have been flagging all along, not new false positives — a genuinely inert or hidden-marked <area> in a used map, or one behind an aria-hidden image, is still a real, reachable, unnamed link.

Status: resolved 2026-09-14.

Ancestor/group opacity was treated as an unconditional contrast-computability blocker, even when the backdrop is a single flat resolvable color, fixed

Decision as it stands (before the change): contrast-computable/contrast-minimum/contrast-enhanced all reported cantTell for any text with a fractional-opacity ancestor (group opacity), unconditionally. contrast-all-scenarios.html's case-blocker-ancestor-opacity was the documented example: a <p> with color:#000000; background-color:#ffffff; opacity:1 inside a <div style="opacity:0.5">, itself on a flat white (bgWhite) section.

Why it was questioned: run through scripts/other-engine-corpus-check.js unscoped against every fixture with a case, this was one of a small number of gaps that survived filtering out same-page noise and matched a same-topic finding (color-contrast) from the other tool. Verified directly rather than trusting either side: rendered the fixture in real Chromium and sampled the actual composited pixel color at the text ((126,126,126)) against the background ((255,255,255)) — a deterministic WCAG contrast ratio of ~4.06, which fails AA's 4.5:1 for normal text. The existing getComputabilityBlocker code comment gave the real reason it stayed unconditional: naively combining the existing per-element opacity product (used for the foreground) with the existing ancestor-opacity-aware background walk double-counts the ancestor's opacity, confirmed by hand with a second scenario (a colored ancestor background rather than white-on-white, where the two independently-computed colors landed on a visibly different, wrong answer from real compositing).

Decision (2026-09-14): resolveGroupOpacityColors in contrast-helpers.js resolves both colors in a single walk instead of combining two independently-computed ones after the fact: a background accumulator (as computeEffectiveBackground already builds) and a parallel foreground accumulator that receives the text's own color as its innermost layer, with every ancestor's own background-color and opacity applied to both accumulators in lockstep. Nothing is combined after the fact, so there is nothing left to double-count, and it handles any number of nested opacity ancestors — with or without their own background-color — uniformly. It only bails (falls back to the existing ANCESTOR_OPACITY cantTell, unchanged) for a blend-mode/filter/background-image anywhere in the chain, a missing declared text color, or a background that never reaches full opacity even after the whole chain is walked — the genuinely hard general case (a group over a gradient or other unresolvable content) stays exactly as conservative as before. getComputabilityBlocker, computeEffectiveForeground and computeEffectiveBackground all consult it transparently, so no call site elsewhere needed to change. contrast-all-scenarios.html gained a second ancestor-opacity case (a gradient sitting further out, beyond the resolvable opacity ancestor) specifically to keep ANCESTOR_OPACITY reachable in the fixture corpus scripts/generate-finding-ids.js scans — removing that reachability would have silently dropped a still-real reason code from the finding-ids inventory.

Accepted cost: the fixture's case-blocker-ancestor-opacity moves from cantTell (computable) to pass for contrast-computable, and from excluded to fail for contrast-minimum/contrast-enhanced — a real finding these rules should have caught all along, not a new false positive.

Status: resolved 2026-09-14.

Six ARIA-widget naming rules credited a <label> to elements it never actually named, via an unguarded local copy of the label-association lookup

Decision as it stands (before the change): combobox-name-present, listbox-name-present, searchbox-name-present, spinbutton-name-present, textbox-name-present and slider-name-present each carried an identical local getNativeLabelText(el), falling back to el.closest('label') (wrapping) or a label[for] map (buildLabelForMap) with no check that el was actually a labelable element. Each rule's own JSDoc already stated the intended contract correctly ("On a labelable element (<input role="combobox">) an associated <label> counts as well") — the implementation just never enforced the "labelable" half of it.

Why it was questioned: run through scripts/other-engine-corpus-check.js unscoped, combobox-name-present/listbox-name-present/searchbox-name-present/spinbutton-name-present/textbox-name-present all disagreed with the same other-tool rule, aria-input-field-name — a recurring pattern across five rules pointed at one systemic thing rather than five coincidences. Verified directly: rendered <label>Wrapped label text <div role="combobox" tabindex="0"></div></label> in real Chromium and Firefox and read the actual accessibility tree (ariaSnapshot()) — the combobox has no accessible name in either browser, wrapping or for-based. slider-name-present shared the same buggy local function but never actually triggered it: its own call site already gates the label lookup behind kind === 'native-slider', so it was dead code there, not a live bug — its fixture already had the correct expectation on record.

Decision (2026-09-14): all six rules' local getNativeLabelText now delegate to the shared getAssociatedLabelElements (dom-helpers.js), which already correctly restricted the wrapping case to wrap.querySelector(LABELABLE_SELECTOR) === el but, it turned out, never applied the equivalent check to the for-based case — so getAssociatedLabelElements itself gained a labelable check up front, benefiting every existing caller, not just these six. That surfaced a second instance of the identical bug one level down: a pinned unit test in tests/core/dom-helpers-name-computation.test.js explicitly asserted that <label for="x">Name</label><div id="x" role="button"> resolves to the name "Name" — verified against real Chromium/Firefox as wrong the same way, and corrected alongside a new test pinning the correct behavior for a genuinely labelable target.

Accepted cost: each of the five previously-affected rules (not slider-name-present) gains two new failing fixture cases — a wrapping-<label> case and a label[for]-plus-empty-content-title-fallback case — both real markup a screen reader user hears as unnamed, not new false positives.

Status: resolved 2026-09-14. form-control-single-label.js and binary-control-name-present.js have their own separate, similarly-unguarded closest('label') fallbacks, not touched here since their applicability already appears scoped to genuinely labelable elements (not verified) — worth a look if they ever start evaluating non-labelable targets.