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 = `
+
+ `;
+ 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 = `
+
+ `;
+ 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