fix(cli): occlusion-probe false positives — pointer-events blindness, low-alpha gradients, not-yet-entered text#2357
Conversation
- pointer-events:none text is invisible to elementFromPoint, so the probe always hit whatever paints beneath and misread visible text as buried; restore hit-testing on the element for the duration of the probe - a backgroundImage counted as opaque regardless of alpha, so a 4%-alpha grid/scrim gradient qualified as an occluder; gradients now occlude only when their colours reach alpha > 0.6 (url() images unchanged) - a visible container whose every text-bearing descendant is still at opacity 0 (entrance not started) was probed anyway; skip when no text ink is on screen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ed05b2e to
acfd98f
Compare
…t compositing, whitespace ink - restore hit-testing for ALL pointer-events:none elements during the text audit pass (not just the probed text): an occluder that itself carries pointer-events:none is invisible to elementFromPoint, which made truly buried text read as clean once the text alone became hittable - hasVisibleTextInk ignores whitespace-only text nodes (indented markup defeated the gate) and uses a 0.05 floor so mid-fade text keeps its persistence occurrences - hasOpaqueBackground composites gradient alpha with background-color (two 0.5-alpha layers paint at ~0.75); gradientMaxAlpha returns opaque for any colour function it cannot score (oklch/lab/...); percentage alpha values now parse as fractions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A transparent layer that becomes hittable (pointer-events restored) must not mask an opaque occluder painting beneath it — single-point elementFromPoint returned the transparent top and dropped two genuinely buried cases in the census acceptance run; the stack walk keeps 10/10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Acceptance run against the production census sample (109 adjudicated
Of the remaining 31, spot-checks show partial/semi-transparent occlusion (e.g. an opaque callout covering 33% of an atomic label; a 0.71-opacity node leaving only 1.8% of the glyph pixels visible) — cases where the finding is defensible, not probe artifacts. Net: the pure false-positive classes (pointer-events blindness, low-alpha decorations, not-yet-entered text) are eliminated while every confirmed burial still flags. Round-2 also hardened per review: document-wide hit-testing restore (an occluder with its own pointer-events:none was structurally undetectable), |
There was a problem hiding this comment.
Strong direction overall: restoring hit-testing inside the finally path (packages/cli/src/commands/layout-audit.browser.js:994) closes the pointer-events blind spot without leaving mutated page state, and the new browser coverage exercises the reported false-positive classes.
One correctness issue remains in the gradient handling:
gradientMaxAlpha(packages/cli/src/commands/layout-audit.browser.js:677) takes the maximum color-stop alpha across the entirebackground-imagedeclaration, thenhasOpaqueBackgroundcomposites that value only once at line 674. CSS permits multiple comma-separated background-image layers, so this undercounts stacked translucent gradients. In Chrome, two full-coverlinear-gradient(rgba(0,0,0,.5), rgba(0,0,0,.5))layers have combined alpha1 - (1 - .5)^2 = .75, above this probe threshold, while the current implementation returns.5and suppressestext_occluded. That reintroduces a false negative for genuinely buried text. Please split top-level image layers while respecting nested parentheses, compute each layer alpha, composite the layers, and add this case as a regression test.
Non-blocking contract mismatch: hasVisibleTextInk uses 0.05 at line 837, while the PR description says >= 0.2; the test at packages/cli/src/commands/layout-audit.browser.test.ts:979 only covers exactly zero. Please align the description or implementation and pin the intended boundary.
All CI checks are green on 3e880803.
Verdict: REQUEST CHANGES
Reasoning: The main fixes are well-scoped, but multi-layer gradient compositing can hide a real occlusion and needs a regression before merge.
— Home
jrusso1020
left a comment
There was a problem hiding this comment.
Independent pass as James asked — additive to Magi's, not a re-run. Not approving: deferring to Magi's standing CHANGES_REQUESTED.
Concur with Magi's blocker (verified independently). gradientMaxAlpha returns max() over all colours, so stacked background-image layers under-count: two 0.5-alpha gradient layers should composite to ~0.75 (>0.6) but the probe sees 0.5 (<0.6), hasOpaqueBackground returns false, and genuinely buried text goes unflagged. Per-layer compositing with the same 1 − ∏(1 − aᵢ) you already apply for image-over-colour, plus Magi's regression test, is the fix to land before merge.
One additive finding — occluderAt stack-walk (non-blocking, not a regression). The new walk correctly skips transparent top layers to reach an opaque one below — but it return nulls (aborts the whole probe) on the first foreign hit matching sharedPreserve3d or isCrossSceneTransitionOverlap. Both predicates are pair-specific to that hit, so a transparent decorative layer that happens to share the text's 3D context (or is a mid-fade cross-scene overlap) will now mask a genuine opaque occluder sitting below it in the stack. The old single-hit code had the same blind spot (so this is not a regression), but the stack-walk is exactly where to close it: continue past those two instead of return null, so the walk keeps looking deeper. Safe-direction either way — it only ever misses occlusions — hence non-blocking, but worth a thought while you're already reworking this function.
Worth calling out (positive): the alphaFromParts % handling (Change 1) fixes a real pre-existing bug — parsePx("50%") returned 50, so percentage-alpha colours were read as fully opaque; the /100 makes rgb(… / 50%) resolve to 0.5 correctly.
Everything else traced clean: url()→opaque, the unknown-colour-fn→opaque fallback (safe/conservative), the pointer-events save/restore + try/finally hygiene, and the not-yet-entered hasVisibleTextInk suppression. CI green at 3e880803.
— Rames Jusso
…uder opacity Two 0.5-alpha gradient layers paint at 0.75 combined; taking the max color-stop alpha across the whole declaration under-counted them and suppressed real text_occluded findings. Split layers at top-level commas (paren-aware), score each, composite as 1-prod(1-a_i). Also pins the 0.05 text-ink floor with a boundary test (review feedback on #2357). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sharedPreserve3d and isCrossSceneTransitionOverlap excuse one hit, not the whole probe; returning null let a transparent decorative layer in the text's 3D context mask a real occluder below it (review feedback). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed both reviews: Blocking — multi-layer Non-blocking — ink-floor contract: the PR description said ≥ 0.2 but the code uses 0.05 — the description was stale; 0.05 is deliberate so persistence sampling still sees mid-fade occurrences. Description updated, and the boundary is pinned by tests (opacity 0.06 probes; all-zero-ink skips). Additive — stack-walk exemptions (25bf0c4): took the suggestion. Validation: 47/47 unit tests pass; the 109-case production acceptance rerun is byte-identical to the pre-fix run (buried 10/10 fire, not-entered 18→0 silent, visible-FP fires unchanged at 31) — both changes only tighten detection and reintroduced no false positives on the corpus. |
jrusso1020
left a comment
There was a problem hiding this comment.
Re-review at 25bf0c49 — both concerns resolved, verified at source. Not approving yet: deferring to Magi's standing CHANGES_REQUESTED (Magi owns that blocker's clear) and CI is still mid-flight.
- Magi's blocker (multi-layer gradient) — fixed correctly.
gradientLayersAlphanow composites per background-image layer with1 − ∏(1 − aᵢ)oversplitTopLevelCommas(...), andsplitTopLevelCommassplits on depth-0 commas only — so gradient stops and args insidergba()aren't mis-split as layers. Two 0.5-alpha layers → 0.75 as intended. - My
occluderAtnote — addressed. The two pair-specific exemptions (sharedPreserve3d/isCrossSceneTransitionOverlap) are nowcontinueinstead ofreturn null, so a transparent 3D-sharing or mid-fade top layer no longer masks a genuine opaque occluder deeper in the stack. Confirmed it doesn't over-flag transitions: every cross-scene mid-fade layer is stillcontinued past, so only a same-scene opaque occluder flags.
One gate before merge: CI is still running (regression-shards + Windows render pending). Those shards are the real check on an audit-logic change like this, so worth letting them go green. Once they're green and Magi clears its request, this looks good to me.
— Rames Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-reviewed the incremental changes from 3e880803 through current head 25bf0c499ecb547b0513b2285757fd41c7c1d6a5.
The prior blocker is resolved. splitTopLevelCommas at packages/cli/src/commands/layout-audit.browser.js:686-700 splits only at depth zero, so commas nested inside gradient and rgba(...) parentheses remain within their layer; gradientLayersAlpha at lines 677-684 then composites each layer as 1 - ∏(1 - α). The regression at packages/cli/src/commands/layout-audit.browser.test.ts:970-987 pins both sides: two 0.5 layers become 0.75 and occlude, while one 0.5 layer does not.
The opacity contract is aligned as well: hasVisibleTextInk uses the documented >= 0.05 floor at layout-audit.browser.js:859-866, and the new 0.06 positive-boundary case at layout-audit.browser.test.ts:989-1008 complements the existing all-zero suppression case. Rames's stack-walk note is also fixed by continuing past pair-specific exemptions at layout-audit.browser.js:796-801, with focused coverage at layout-audit.browser.test.ts:946-968.
I inspected the complete two-file diff and the two new commits; no additional regression surfaced. All required checks, both Windows jobs, and all eight regression shards are green on this exact head.
Verdict: APPROVE
Reasoning: The multi-layer gradient correctness blocker and opacity-boundary contract are now implemented and regression-pinned, the additive stack-walk issue is fixed, and the full CI suite is green.
— Deepwork
Problem
A 5-day census of 9,439 production Zephyr compositions run through
hyperframes checkmadetext_occludedthe largest layout error class: 2,013 error findings across 673 comps (7.1%). A pixel-level re-adjudication of 109 randomly sampled cases (hide the text, screenshot before/after, diff the text region — immune to the probe) found the class is mostly noise:<video>layered above the text layer)Three probe defects produce the noise; each reproduces with a minimal comp:
pointer-events:noneblinds the probe. Such text never appears inelementFromPointresults, so every probe point "hits" whatever paints beneath — perfectly visible text reads as buried. 61% of sampled flagged texts carrypointer-events:none(a common Zephyr idiom for decorative labels). Because the condition is persistent, it also defeats persistence-tiering.backgroundImagecounted as opaque. Arepeating-linear-gradientof 4%-alpha hairlines (grid/scrim decoration) qualified as an "opaque element" hiding the text.Controlled repro for 1+2: a plainly visible white headline with
pointer-events:noneunder a 4%-alpha sibling grid →text_occludederror, occluder = the grid.Fix
occlusionCoveragetemporarily restores hit-testing (pointer-events: auto !importantinline, reverted infinally) on the probed element — the probe can now see the text itself; genuinely covered text still resolves to the covering element.hasOpaqueBackgroundtreatsurl()images as before, but gradients now occlude only when their parsed colours reach alpha > 0.6 (gradientMaxAlpha; unparseable named-colour gradients conservatively keep the old opaque behaviour).occludedTextIssuebails when every non-whitespace text node in the subtree sits below effective opacity 0.05 (hasVisibleTextInk) — nothing is painted yet, so nothing can be occluded. The floor is deliberately low so persistence sampling still sees mid-fade occurrences; boundary pinned by test (0.06 probes, 0 skips).hasOpaqueBackgroundsplitsbackground-imageinto top-level comma layers (paren-aware) and composites their alphas with the background colour — two stacked 0.5-alpha gradients paint at 0.75 and count as an occluder (review feedback).Verification
vitest: layout-audit.browser suite 42/42 (38 existing + 4 new: probe-restores-hit-testing, low-alpha gradient not an occluder, opaque gradient still an occluder, opacity-0 text skipped).<video>/image above the text layer) still flag — the probe resolves to the covering element regardless of the text's pointer-events.🤖 Generated with Claude Code