fix(cli): flag text with an effectively transparent fill in check#2266
Conversation
Wild report (5th in cluster, CLI 0.7.53): snapshots omitted all text while check passed — text painting with a transparent -webkit-text-fill-color (which overrides `color` for the glyph fill AND inherits, so a parent's transparent fill silently blanks descendant text that has its own opaque `color`) renders invisible, but every geometry/occlusion/contrast audit missed it. Contrast in particular reads `color`, not the fill that actually paints, so white-`color` + transparent-fill text scored as high-contrast and passed. Add an invisible-text detector to the layout audit: flag any text element whose effective fill (computed -webkit-text-fill-color, which already resolves to `color` when unset) is transparent. Gradient/clipped text (background-clip:text) legitimately uses a transparent fill and is excluded. Verified: check now fails on an inherited-transparent-fill fixture (text_not_painted) while gradient text, body-inherited color, sub-composition color, and real registry examples stay clean.
miga-heygen
left a comment
There was a problem hiding this comment.
Review — fix(cli): flag text with an effectively transparent fill in check
SSOT check: clean. invisibleTextIssue introduces a genuinely new predicate — -webkit-text-fill-color effective fill — that no existing audit owns (contrast reads color, geometry reads bounding boxes). No decision overlap with occludedTextIssue or the contrast check.
Canonical helpers reused correctly: colorAlpha (existing), textRectFor, textContentFor, selectorFor — all shared audit primitives already in scope, no duplication.
Gradient/clipped-text exclusion: checks both -webkit-background-clip and background-clip (vendor-prefix coverage), regex /text/i matches correctly. Legitimate exclusion — clipped backgrounds paint the glyphs even with transparent fill.
Fill fallback: cs.getPropertyValue("-webkit-text-fill-color") || cs.color — in Chrome the computed value already resolves to color when unset, so the || is defensive-only for engines where the property might not resolve. Not harmful, just belt-and-suspenders.
Type registration: "text_not_painted" added to both LayoutIssueCode union and LAYOUT_ISSUE_CODES array. In sync.
Calling convention: follows the same const x = xIssue(element, time); if (x) issues.push(x) pattern as every other audit check. Consistent.
Nit — test coverage: text_occluded (the analogous sibling check) has tests in layout-audit.browser.test.ts. This PR has thorough manual fixture validation but no automated test for text_not_painted. A happy-path test (transparent fill → flagged) and gradient exclusion test (background-clip: text → not flagged) would lock down the contract. Non-blocking — the manual validation table is solid.
LGTM — no issues found.
— Miga
miguel-heygen
left a comment
There was a problem hiding this comment.
Blocker: this adds a browser-specific predicate with no automated browser regression coverage. Add focused tests exercising (1) direct transparent -webkit-text-fill-color, (2) inherited transparent fill overriding an opaque child color, (3) opaque/invalid/absent fill fallback, and (4) gradient text exclusion only when a usable background actually paints (background-clip:text including webkit variant); a broken/missing gradient must remain reportable. Also verify glyph-bearing text visibility and stable code/dedup shape. Current Test is failing and Windows/render checks are pending.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 9ae84c6.
Real audit gap, precise fix. The observation that -webkit-text-fill-color inherits AND overrides color for the fill that actually paints is exactly why every other text-visibility audit (geometry / occlusion / contrast) missed this — contrast reads color, not the fill. Reading the computed -webkit-text-fill-color (which resolves to color when unset per spec) is the right lens, and the gradient-text exclusion via background-clip: text is correct — clipped text legitimately uses a transparent fill because the clipped background paints the glyphs.
The main-loop invocation slots in cleanly alongside the sibling occludedTextIssue, and the isVisibleElement prefilter at line 877 already discards elements with opacity chain < 0.2, so the new check only fires on elements that are otherwise "visibly present but paint invisibly" — narrow, correct scope.
Concerns
-
packages/cli/src/commands/layout-audit.browser.js:809— missing automated test coverage. Agree with Miga's read; want to sharpen the ask into concrete cases so it's a one-shot addition tolayout-audit.browser.test.ts(which already has 8 tests for the siblingtext_occluded):- Parent
-webkit-text-fill-color: transparent+ child opaquecolor(the wild-report shape) → assertsissues.some(i => i.code === "text_not_painted")istrue. - Gradient text (
background-clip: text+-webkit-text-fill-color: transparent) → assertstext_not_paintedisfalse. - Plain visible text (baseline) → asserts
text_not_paintedisfalse. - Element with the transparent fill but empty text content → asserts
text_not_paintedisfalse(theif (!text) return nullearly return). - Element with
color: transparentand no explicit-webkit-text-fill-color— sanity check that the||fallback path fires and the check still flags. This locks in the docstring claim that unset-webkit-text-fill-colorresolves tocolor.
These map 1:1 to the fixture table in the PR body, so no new fixture cost — just move the manual validation into the vitest suite. Given the wild-report cluster is 5 deep on inherited-color glyph loss, having regression coverage the next time someone touches contrast/occlusion is worth it.
- Parent
Questions
packages/cli/src/commands/layout-audit.browser.js:809— opt-out symmetry. The siblingtext_occludedhashasAllowOcclusionFlag(data-layout-allow-occlusion) for intentional layering.text_not_paintedhas thebackground-clip: textexclusion for gradient text, but no general "I know this is invisible on purpose" opt-out. Given theisVisibleElement(element, 0.2)prefilter already kills the common opacity-fade case, I can't concretely name a legitimate remaining use case (a sentinel withcolor: transparentat full opacity would be unusual) — so maybe not needed. But worth asking: did you consider adata-layout-allow-transparent-fillflag for parity withtext_occluded, or is the current binary exclusion sufficient given the prefilter narrows the surface?
What I didn't verify
- The
Testjob in CI shows FAILURE — it's@hyperframes/playerECONNREFUSED tolocalhost:3000in happy-dom fetch tests. Nothing in this diff touches player; looks like infra flake / port unavailable, not a real regression. Worth a re-run to confirm. - Downstream consumers of
LAYOUT_ISSUE_CODES— if any exhaustive switch onLayoutIssueCodeexists in a caller, TypeScript would flag it, so this is likely fine. Didn't chase to prove it.
LGTM from my side once tests are added (or explicit follow-up with an issue link).
The layout-audit test mock returns computed styles as plain camelCase properties (no getPropertyValue), so the invisible-text detector threw 'cs.getPropertyValue is not a function' and broke the whole audit in CI. Read webkitTextFillColor/webkitBackgroundClip/backgroundClip by property to match the rest of the script (works in a real browser too). Adds tests: flags transparent -webkit-text-fill-color, ignores opaque color, ignores gradient text (background-clip:text).
… the glyphs Addresses review on #2266: the gradient-text exclusion was too broad — any background-clip:text skipped the invisible-text check, so a broken/missing gradient (clip:text with no image and a transparent background, which paints nothing) went unreported. Now exclude only when a real background fills the glyphs (background-image != none, or an opaque background-color). Expands the test suite to the reviewer's full case set: direct transparent fill, inherited transparent fill over an opaque child color, color:transparent fallback, opaque baseline, gradient-over-real-background exclusion, broken-gradient still flagged, and empty-text no-op.
|
Addressed in CI failure — the Gradient exclusion refinement (Miguel's blocker) — the exclusion now only skips clipped text when a background actually paints the glyphs: Tests — expanded
Rames' opt-out question — I skipped a |
Superseded by fa9024 test coverage and gradient semantics fixes.
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-reviewed current head fa90240. Added browser regression coverage now exercises direct and inherited transparent fills, color fallback, valid gradient clipping, broken/missing gradient reporting, and text-content filtering. Predicate remains SSOT-owned and stable. Required CI is green. LGTM pending independent approval.
What
Adds an invisible-text detector to
check's layout audit: any text element whose effective glyph fill is transparent is flaggedtext_not_painted(error). The effective fill is the computed-webkit-text-fill-color, which already resolves tocolorwhen unset — so it's the value that actually paints. Gradient/clipped text (background-clip: text) legitimately uses a transparent fill and is excluded.Why
Wild report (5th in the inherited-color snapshot glyph-loss cluster, CLI 0.7.53, macOS):
hyperframes snapshotomitted all text whilecheckpassed; explicitcolor+-webkit-text-fill-coloron the text classes fixed it. The reporter explicitly noted "the check did not catch the missing rendered glyphs."Root cause of the check gap:
-webkit-text-fill-coloroverridescolorfor the glyph fill and inherits, so a parent'stransparentfill silently blanks descendant text that has its own opaquecolor. Every audit missed it — geometry/occlusion see a present box; the contrast check readscolor(not the fill that paints), socolor: white+ transparent-fill text scored as high-contrast and passed. The text renders invisibly with no signal.This is distinct from the remote-font localization fix (#2264): that was font family falling back to a system sans; this is any-font text painting invisibly due to fill.
Validation
Reproduced deterministically with fixtures +
check --json:-webkit-text-fill-color: transparent, child opaquecolor(invisible text)ok: true, 0 findingsok: false,text_not_painted✓background-clip: text+ transparent fill)text_not_painted(excluded) ✓color, no explicit color (visible)color(visible)text_not_painted(no false positives) ✓checkBrowsertest suite green; lint/format/build clean.Notes
Scoped to the check-side detection gap the report called out. The separate question of why snapshot's capture differs from render's for a given composition wasn't reproducible across five fixtures (snapshot renders inherited-color and gradient text correctly), consistent with the reporter's own note that the final render was clean — so the actionable, verifiable defect here is that
checknow catches invisible text before it ships.