Skip to content

fix(cli): occlusion-probe false positives — pointer-events blindness, low-alpha gradients, not-yet-entered text#2357

Merged
xuanruli merged 5 commits into
mainfrom
fix/occlusion-probe-false-positives
Jul 13, 2026
Merged

fix(cli): occlusion-probe false positives — pointer-events blindness, low-alpha gradients, not-yet-entered text#2357
xuanruli merged 5 commits into
mainfrom
fix/occlusion-probe-false-positives

Conversation

@xuanruli

@xuanruli xuanruli commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Problem

A 5-day census of 9,439 production Zephyr compositions run through hyperframes check made text_occluded the 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:

observation share
text plainly visible on screen 74%
text had not entered yet (opacity 0) at the sampled time 17%
genuinely buried (e.g. a <video> layered above the text layer) 9%

Three probe defects produce the noise; each reproduces with a minimal comp:

  1. pointer-events:none blinds the probe. Such text never appears in elementFromPoint results, so every probe point "hits" whatever paints beneath — perfectly visible text reads as buried. 61% of sampled flagged texts carry pointer-events:none (a common Zephyr idiom for decorative labels). Because the condition is persistent, it also defeats persistence-tiering.
  2. Any backgroundImage counted as opaque. A repeating-linear-gradient of 4%-alpha hairlines (grid/scrim decoration) qualified as an "opaque element" hiding the text.
  3. Not-yet-entered text was probed. A visible container whose every text-bearing descendant is still at opacity 0 (entrance hasn't started) gets flagged even though there is no ink on screen to occlude.

Controlled repro for 1+2: a plainly visible white headline with pointer-events:none under a 4%-alpha sibling grid → text_occluded error, occluder = the grid.

Fix

  • occlusionCoverage temporarily restores hit-testing (pointer-events: auto !important inline, reverted in finally) on the probed element — the probe can now see the text itself; genuinely covered text still resolves to the covering element.
  • hasOpaqueBackground treats url() 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).
  • occludedTextIssue bails 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).
  • hasOpaqueBackground splits background-image into 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).
  • Against the census sample: the three mechanisms cover 91% of the sampled false positives; the 9% genuinely-buried cases (opaque <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

- 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>
@xuanruli xuanruli force-pushed the fix/occlusion-probe-false-positives branch from ed05b2e to acfd98f Compare July 13, 2026 20:15
xuanruli and others added 2 commits July 13, 2026 20:29
…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>
@xuanruli

Copy link
Copy Markdown
Contributor Author

Acceptance run against the production census sample (109 adjudicated text_occluded error cases, fixed engine injected into real Chrome at each finding's time):

adjudicated group before PR after PR
genuinely buried (10) 10 fire 10 fire — zero true-positive loss
text not entered yet (18) 18 fire 0 fire
text visible on screen (81) 81 fire 31 fire

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), elementsFromPoint stack walk (a transparent hittable layer must not mask an opaque one beneath), whitespace-immune ink gate, gradient×background-color alpha compositing, conservative handling of unparseable color functions, percentage alpha parsing.

@xuanruli xuanruli marked this pull request as ready for review July 13, 2026 20:45

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 entire background-image declaration, then hasOpaqueBackground composites 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-cover linear-gradient(rgba(0,0,0,.5), rgba(0,0,0,.5)) layers have combined alpha 1 - (1 - .5)^2 = .75, above this probe threshold, while the current implementation returns .5 and suppresses text_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 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

xuanruli and others added 2 commits July 13, 2026 21:02
…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>
@xuanruli

Copy link
Copy Markdown
Contributor Author

Addressed both reviews:

Blocking — multi-layer background-image compositing (431f72d): hasOpaqueBackground now splits the declaration at top-level commas (paren-aware splitTopLevelCommas), scores each layer via gradientMaxAlpha, and composites as 1 − ∏(1 − αᵢ) before folding in the background colour. The requested regression is in: two stacked linear-gradient(rgba(0,0,0,.5), rgba(0,0,0,.5)) layers (0.75 combined) now count as an occluder, while a single 0.5-alpha layer still doesn't. The unknown-colour-function → opaque fallback applies per layer, so any unscorable layer still forces the conservative result.

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. sharedPreserve3d / isCrossSceneTransitionOverlap now continue instead of aborting the probe, so a transparent layer sharing the text's 3D context can no longer mask a real occluder deeper in the stack. Regression test added (transparent preserve-3d sibling above an opaque panel → still flagged).

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 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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. gradientLayersAlpha now composites per background-image layer with 1 − ∏(1 − aᵢ) over splitTopLevelCommas(...), and splitTopLevelCommas splits on depth-0 commas only — so gradient stops and args inside rgba() aren't mis-split as layers. Two 0.5-alpha layers → 0.75 as intended.
  • My occluderAt note — addressed. The two pair-specific exemptions (sharedPreserve3d / isCrossSceneTransitionOverlap) are now continue instead of return 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 still continued 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 miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

@xuanruli xuanruli merged commit 3e3b37d into main Jul 13, 2026
50 checks passed
@xuanruli xuanruli deleted the fix/occlusion-probe-false-positives branch July 13, 2026 21:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants