Skip to content

fix(cli): flag text with an effectively transparent fill in check#2266

Merged
vanceingalls merged 3 commits into
mainfrom
07-11-fix-cli-check-detect-invisible-text
Jul 12, 2026
Merged

fix(cli): flag text with an effectively transparent fill in check#2266
vanceingalls merged 3 commits into
mainfrom
07-11-fix-cli-check-detect-invisible-text

Conversation

@vanceingalls

Copy link
Copy Markdown
Collaborator

What

Adds an invisible-text detector to check's layout audit: any text element whose effective glyph fill is transparent is flagged text_not_painted (error). The effective fill is the computed -webkit-text-fill-color, which already resolves to color when 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 snapshot omitted all text while check passed; explicit color + -webkit-text-fill-color on 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-color overrides color for the glyph fill and inherits, so a parent's transparent fill silently blanks descendant text that has its own opaque color. Every audit missed it — geometry/occlusion see a present box; the contrast check reads color (not the fill that paints), so color: 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:

Fixture Before After
Parent -webkit-text-fill-color: transparent, child opaque color (invisible text) ok: true, 0 findings ok: false, text_not_painted
Gradient text (background-clip: text + transparent fill) no text_not_painted (excluded) ✓
Body-inherited color, no explicit color (visible) clean ✓
Sub-composition inherited color (visible) clean ✓
Real registry examples (kinetic-type, swiss-grid, nyt-graph) 0 text_not_painted (no false positives) ✓

checkBrowser test 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 check now catches invisible text before it ships.

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 miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 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.

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 james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 to layout-audit.browser.test.ts (which already has 8 tests for the sibling text_occluded):

    1. Parent -webkit-text-fill-color: transparent + child opaque color (the wild-report shape) → asserts issues.some(i => i.code === "text_not_painted") is true.
    2. Gradient text (background-clip: text + -webkit-text-fill-color: transparent) → asserts text_not_painted is false.
    3. Plain visible text (baseline) → asserts text_not_painted is false.
    4. Element with the transparent fill but empty text content → asserts text_not_painted is false (the if (!text) return null early return).
    5. Element with color: transparent and 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-color resolves to color.

    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.

Questions

  • packages/cli/src/commands/layout-audit.browser.js:809 — opt-out symmetry. The sibling text_occluded has hasAllowOcclusionFlag (data-layout-allow-occlusion) for intentional layering. text_not_painted has the background-clip: text exclusion for gradient text, but no general "I know this is invisible on purpose" opt-out. Given the isVisibleElement(element, 0.2) prefilter already kills the common opacity-fade case, I can't concretely name a legitimate remaining use case (a sentinel with color: transparent at full opacity would be unusual) — so maybe not needed. But worth asking: did you consider a data-layout-allow-transparent-fill flag for parity with text_occluded, or is the current binary exclusion sufficient given the prefilter narrows the surface?

What I didn't verify

  • The Test job in CI shows FAILURE — it's @hyperframes/player ECONNREFUSED to localhost:3000 in 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 on LayoutIssueCode exists 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).

Review by Rames D Jusso

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.
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Addressed in fa902400c (+ a8fd8562a for the CI-blocking mock issue).

CI failure — the Test job was failing on my own change, not the player flake: the layout-audit test mock returns computed styles as plain camelCase properties, so cs.getPropertyValue(...) threw and broke the whole audit. Switched to property access (cs.webkitTextFillColor / cs.webkitBackgroundClip / cs.backgroundColor), which is how the rest of the script reads computed style and works in a real browser.

Gradient exclusion refinement (Miguel's blocker) — the exclusion now only skips clipped text when a background actually paints the glyphs: background-image != none, or an opaque background-color. A background-clip: text with no gradient/image and a transparent background paints nothing → stays reportable. Verified end-to-end: real gradient text (with a gradient background) is not flagged; a broken clip:text is.

Tests — expanded layout-audit.browser.test.ts to the full case set you both asked for (maps 1:1 to the PR fixture table):

  1. Direct transparent -webkit-text-fill-color → flagged.
  2. Inherited transparent fill overriding an opaque child color → flagged (the wild-report shape; the mock resolves computed fill per element exactly as the browser does).
  3. color: transparent with no explicit fill → flagged (locks the || cs.color fallback / "unset fill resolves to color" claim).
  4. Opaque color, default fill → not flagged.
  5. Gradient text over a real background → not flagged.
  6. background-clip: text with no painting background → still flagged.
  7. Transparent fill but empty text content → not flagged (the if (!text) early return).

Rames' opt-out question — I skipped a data-layout-allow-transparent-fill flag. The isVisibleElement(el, 0.2) prefilter already discards opacity-fade cases, the refined exclusion covers legitimate clipped text, and I couldn't name a real remaining case (a full-opacity color: transparent sentinel with glyph text would be unusual and arguably worth flagging). Easy to add for text_occluded parity if a concrete case turns up — happy to as a follow-up.

@miguel-heygen miguel-heygen dismissed their stale review July 12, 2026 00:06

Superseded by fa9024 test coverage and gradient semantics fixes.

@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 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.

@vanceingalls vanceingalls merged commit bd86fb8 into main Jul 12, 2026
43 checks passed
@vanceingalls vanceingalls deleted the 07-11-fix-cli-check-detect-invisible-text branch July 12, 2026 00:10
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.

4 participants