diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js index d1f5fe1478..7b45f6e7fb 100644 --- a/packages/cli/src/commands/contrast-audit.browser.js +++ b/packages/cli/src/commands/contrast-audit.browser.js @@ -88,6 +88,25 @@ window.__contrastAudit = async function (imgBase64, time) { var cs = getComputedStyle(el); if (cs.visibility === "hidden" || cs.display === "none") continue; if (parseFloat(cs.opacity) <= 0.01) continue; + // Also skip when an ANCESTOR is effectively invisible (opacity≈0 / hidden / display:none). + // Karaoke captions keep every word at opacity 1 but toggle the GROUP's opacity per beat, + // so an inactive word's OWN opacity is 1 — only an ancestor reveals it's hidden. Without + // this, the hidden caption words flood the audit with false ~1:1 contrast warnings. + var anc = el.parentElement, + ancHidden = false; + while (anc && anc !== document.body) { + var acs = getComputedStyle(anc); + if ( + acs.visibility === "hidden" || + acs.display === "none" || + parseFloat(acs.opacity) <= 0.01 + ) { + ancHidden = true; + break; + } + anc = anc.parentElement; + } + if (ancHidden) continue; var rect = el.getBoundingClientRect(); if (rect.width < 8 || rect.height < 8) continue; if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= w || rect.top >= h) continue; diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 76ca106a8a..b3722ecb37 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -27,14 +27,18 @@ return Math.round(value * 100) / 100; } - function overflowFor(subject, container, tolerance) { + function overflowFor(subject, container, tolerance, vTolerance) { + // Horizontal axis uses `tolerance`; vertical axis uses `vTolerance` (defaults to the same). + // A separate vertical tolerance lets text overflow checks absorb glyph ink that exceeds a + // snug line-height — see textOverflowIssues. + if (vTolerance == null) vTolerance = tolerance; const overflow = {}; if (subject.left < container.left - tolerance) overflow.left = round(container.left - subject.left); if (subject.right > container.right + tolerance) overflow.right = round(subject.right - container.right); - if (subject.top < container.top - tolerance) overflow.top = round(container.top - subject.top); - if (subject.bottom > container.bottom + tolerance) + if (subject.top < container.top - vTolerance) overflow.top = round(container.top - subject.top); + if (subject.bottom > container.bottom + vTolerance) overflow.bottom = round(subject.bottom - container.bottom); return Object.keys(overflow).length > 0 ? overflow : null; } @@ -319,9 +323,22 @@ const container = nearestConstraint(element, root, rootRect); const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect()); - const containerOverflow = overflowFor(textRect, containerRect, tolerance); + // Glyph ink (ascenders / descenders / accents / heavy display faces) routinely exceeds a + // snug line-height box by a few px, proportional to font size. When the constraining box + // does NOT clip, that vertical spill is normal typography — it shows in the padding, nothing + // is hidden — not a layout defect (it false-flagged caption words). Allow a font-metric + // vertical tolerance there; keep it tight when the box actually clips (a real cut-off) and + // always tight horizontally (too-wide text is a real wrap/legibility issue). + const elementStyle = getComputedStyle(element); + const containerClips = clipsOverflow( + container === root ? getComputedStyle(root) : getComputedStyle(container), + ); + const verticalTolerance = containerClips + ? tolerance + : Math.max(tolerance, parsePx(elementStyle.fontSize) * 0.2); + const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance); if (containerOverflow && !hasAllowOverflowFlag(element)) { - const style = getComputedStyle(element); + const style = elementStyle; issues.push({ code: "text_box_overflow", severity: "error", diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index ab592ae2de..b6917f00a1 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -98,6 +98,42 @@ describe("layout-audit.browser", () => { expect(runAudit()).toEqual([]); }); + + it("does not flag glyph-ink vertical spill within the font-metric band on a non-clipping box", () => { + // A painted, non-clipping caption-word-like box whose glyph ink (text rect) exceeds its snug + // line-height box by a few px vertically — normal typography, nothing is clipped. (fontSize + // 36 → vertical tolerance ~7.2px; the ink spills ~5px each side, well within it.) + document.body.innerHTML = ` +
+
crews,
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + bubble: rect({ left: 80, top: 120, width: 400, height: 80 }), + text: rect({ left: 100, top: 115, width: 300, height: 90 }), + }); + installAuditScript(); + + expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(false); + }); + + it("still flags vertical text overflow beyond the font-metric band", () => { + // Ink is 40px / 80px beyond the box — far past the ~7px font-metric band: a real overflow. + document.body.innerHTML = ` +
+
two crammed lines
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + bubble: rect({ left: 80, top: 120, width: 400, height: 80 }), + text: rect({ left: 100, top: 80, width: 300, height: 200 }), + }); + installAuditScript(); + + expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true); + }); }); describe("layout-audit.browser content overlap", () => { diff --git a/packages/core/src/lint/rules/captions.test.ts b/packages/core/src/lint/rules/captions.test.ts index a954145088..6977acb6e6 100644 --- a/packages/core/src/lint/rules/captions.test.ts +++ b/packages/core/src/lint/rules/captions.test.ts @@ -72,6 +72,26 @@ describe("caption rules", () => { expect(finding).toBeUndefined(); }); + it("does not warn on a content frame that only mentions karaoke in a comment", async () => { + const html = ``; + const result = await lintHyperframeHtml(html, { isSubComposition: true }); + const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill"); + expect(finding).toBeUndefined(); + }); + it("warns when caption group has nowrap without max-width", async () => { const html = ` diff --git a/packages/core/src/lint/rules/captions.ts b/packages/core/src/lint/rules/captions.ts index d5c9d0e3ab..21af66d0e7 100644 --- a/packages/core/src/lint/rules/captions.ts +++ b/packages/core/src/lint/rules/captions.ts @@ -30,8 +30,17 @@ function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | n export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ // caption_exit_missing_hard_kill - ({ scripts }) => { + ({ scripts, styles, options, rootCompositionId }) => { const findings: HyperframeLintFinding[] = []; + // Only the ACTUAL captions composition. A content frame that merely mentions + // "karaoke" / "caption-*" in a comment (or uses an unrelated forEach + opacity:0 + // screen-swap) is NOT captions — gating here prevents the false positive that fired + // on a content frame whose only caption signal was a descriptive comment. + const isCaptionComposition = + Boolean(options.filePath && /caption/i.test(options.filePath)) || + rootCompositionId === "captions" || + styles.some((s) => /\.caption[-_]?(?:group|word|line|block)\b|\.cg-/.test(s.content)); + if (!isCaptionComposition) return findings; for (const script of scripts) { const content = script.content; const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content); diff --git a/packages/core/src/lint/rules/media.test.ts b/packages/core/src/lint/rules/media.test.ts index 68efbed88e..5a38bf02c5 100644 --- a/packages/core/src/lint/rules/media.test.ts +++ b/packages/core/src/lint/rules/media.test.ts @@ -220,4 +220,32 @@ describe("media rules", () => { const finding = result.findings.find((f) => f.code === "imperative_media_control"); expect(finding).toBeUndefined(); }); + + it("flags