mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
fix: content_overlap uses the real CSS box for out-of-flow text
content_overlap measured text overlap via Range.getClientRects(), which reflects a font's own ascent/descent metrics, not the CSS box: a large font-size with a tight line-height keeps the same metrics-rect height regardless of line-height while the real box scales normally. An absolutely/fixed-positioned block has no layout-engine-reserved space (unlike an in-flow flex/grid pair, already exempted), so a genuine CSS-box gap between two such blocks could still measure as overlapping via font metrics -- a false collision report immediately followed by nothing actually unreadable on screen. Adds visibleBoxClientRects, using getBoundingClientRect() instead of Range rects (sharing the existing ancestor-overflow-clip logic via a new clipRectsToOverflowAncestors helper), and switches to it for out-of-flow elements only -- in-flow text keeps the existing font-metrics measurement. Deliberately narrower than "in-flow is always safe": the flex/grid exemption only covers a pair sharing the same flex/grid ancestor, not every in-flow pair, so an unrelated pair of ordinary in-flow blocks with the same font-size/line-height mismatch remains a residual gap, left for a follow-up rather than expanding this fix's scope. Co-Authored-By: Miguel Angel <miguel.sierra@heygen.com>
This commit is contained in:
co-authored by
Miguel Angel
parent
4aa6e17b76
commit
f8d48bfcda
@@ -268,10 +268,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
function visibleTextClientRects(element, directOnly) {
|
||||
// Range rects stay geometrically present outside an overflow clip. Reduce
|
||||
// them in viewport coordinates so overlap measures only paintable text.
|
||||
let rects = textClientRects(element, directOnly).map(toRect);
|
||||
// Client rects stay geometrically present outside an ancestor's overflow clip.
|
||||
// Reduce them in viewport coordinates so overlap measures only paintable area.
|
||||
function clipRectsToOverflowAncestors(element, rects) {
|
||||
for (
|
||||
let ancestor = element.parentElement;
|
||||
ancestor && rects.length > 0;
|
||||
@@ -296,6 +295,25 @@
|
||||
return rects;
|
||||
}
|
||||
|
||||
function visibleTextClientRects(element, directOnly) {
|
||||
return clipRectsToOverflowAncestors(element, textClientRects(element, directOnly).map(toRect));
|
||||
}
|
||||
|
||||
// Range rects follow the font's own ascent/descent, not the CSS box: a large
|
||||
// font-size with a tight line-height keeps the range-rect height while the real
|
||||
// box shrinks around it, so a block can measure as colliding with a neighbor
|
||||
// its box never touches. Only out-of-flow (absolute/fixed) blocks are measured
|
||||
// this way, since they have no layout-engine-reserved space of their own —
|
||||
// matching the reported scenario (an absolutely positioned data-card layout).
|
||||
// In-flow text keeps the font-metrics measurement it has always been audited
|
||||
// with: `isManagedFlowOverlap` only waives a same-flex/grid-container pair,
|
||||
// not every in-flow pair, so this is a deliberately narrower fix than "in-flow
|
||||
// is always safe" — an unrelated pair of ordinary in-flow blocks with the same
|
||||
// font-size/line-height mismatch remains unfixed, left for a follow-up.
|
||||
function visibleBoxClientRects(element) {
|
||||
return clipRectsToOverflowAncestors(element, [toRect(element.getBoundingClientRect())]);
|
||||
}
|
||||
|
||||
function textRectFor(element, directOnly) {
|
||||
return unionRects(textClientRects(element, directOnly));
|
||||
}
|
||||
@@ -627,7 +645,9 @@
|
||||
const blocks = [];
|
||||
for (const element of Array.from(root.querySelectorAll("*"))) {
|
||||
if (!isSolidTextBlock(element)) continue;
|
||||
const rects = visibleTextClientRects(element, true);
|
||||
const rects = isInFlow(element)
|
||||
? visibleTextClientRects(element, true)
|
||||
: visibleBoxClientRects(element);
|
||||
const rect = unionRects(rects);
|
||||
if (rect) blocks.push({ element, rect, rects });
|
||||
}
|
||||
|
||||
@@ -1901,6 +1901,45 @@ describe("layout-audit.browser content overlap", () => {
|
||||
|
||||
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true);
|
||||
});
|
||||
|
||||
// A large font-size with a tight line-height keeps its range-rect height while
|
||||
// the real box shrinks around it, so the two geometries disagree below.
|
||||
it("uses the real CSS box, not font-metrics text rects, for an absolutely positioned block", () => {
|
||||
const issues = auditOverlapScene({
|
||||
a: {
|
||||
position: "absolute",
|
||||
// Metrics band overlaps b's box...
|
||||
textRect: rect({ left: 100, top: 80, width: 200, height: 100 }),
|
||||
// ...but the real box has a genuine 10px gap before b's box starts.
|
||||
boxRect: rect({ left: 100, top: 100, width: 200, height: 50 }),
|
||||
},
|
||||
b: {
|
||||
position: "absolute",
|
||||
textRect: rect({ left: 100, top: 160, width: 200, height: 50 }),
|
||||
boxRect: rect({ left: 100, top: 160, width: 200, height: 50 }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
|
||||
});
|
||||
|
||||
it("still flags a genuine overlap between absolutely positioned real boxes", () => {
|
||||
const issues = auditOverlapScene({
|
||||
a: {
|
||||
position: "absolute",
|
||||
textRect: rect({ left: 100, top: 80, width: 200, height: 100 }),
|
||||
boxRect: rect({ left: 100, top: 100, width: 200, height: 50 }),
|
||||
},
|
||||
b: {
|
||||
position: "absolute",
|
||||
textRect: rect({ left: 100, top: 130, width: 200, height: 50 }),
|
||||
// Real box genuinely overlaps a's real box by 20px this time.
|
||||
boxRect: rect({ left: 100, top: 130, width: 200, height: 50 }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contrast-audit.browser clip-path visibility", () => {
|
||||
@@ -2289,10 +2328,41 @@ function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string })
|
||||
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
|
||||
}
|
||||
|
||||
interface OverlapBlockInput {
|
||||
textRect: DOMRect | DOMRect[];
|
||||
color?: string;
|
||||
attrs?: string;
|
||||
clipPath?: string;
|
||||
position?: string;
|
||||
boxRect?: DOMRect;
|
||||
}
|
||||
|
||||
function overlapBlockRecords(blocks: Record<string, OverlapBlockInput>): {
|
||||
colors: Record<string, string>;
|
||||
clipPaths: Record<string, string>;
|
||||
positions: Record<string, string>;
|
||||
textRects: Record<string, DOMRect[]>;
|
||||
elementRects: Record<string, DOMRect>;
|
||||
} {
|
||||
const colors: Record<string, string> = {};
|
||||
const clipPaths: Record<string, string> = {};
|
||||
const positions: Record<string, string> = {};
|
||||
const textRects: Record<string, DOMRect[]> = {};
|
||||
const elementRects: Record<string, DOMRect> = {};
|
||||
for (const [id, block] of Object.entries(blocks)) {
|
||||
colors[id] = block.color ?? "rgb(0, 0, 0)";
|
||||
clipPaths[id] = block.clipPath ?? "none";
|
||||
positions[id] = block.position ?? "static";
|
||||
textRects[id] = normalizeTextRects(block.textRect);
|
||||
if (block.boxRect) elementRects[id] = block.boxRect;
|
||||
}
|
||||
return { colors, clipPaths, positions, textRects, elementRects };
|
||||
}
|
||||
|
||||
function auditOverlapScene(options: {
|
||||
rootAttrs?: string;
|
||||
a: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
|
||||
b: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
|
||||
a: OverlapBlockInput;
|
||||
b: OverlapBlockInput;
|
||||
}): ReturnType<typeof runAudit> {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080" ${options.rootAttrs ?? ""}>
|
||||
@@ -2300,21 +2370,13 @@ function auditOverlapScene(options: {
|
||||
<div id="b" ${options.b.attrs ?? ""}>Block B copy</div>
|
||||
</div>
|
||||
`;
|
||||
const colors: Record<string, string> = {
|
||||
a: options.a.color ?? "rgb(0, 0, 0)",
|
||||
b: options.b.color ?? "rgb(0, 0, 0)",
|
||||
};
|
||||
const clipPaths: Record<string, string> = {
|
||||
a: options.a.clipPath ?? "none",
|
||||
b: options.b.clipPath ?? "none",
|
||||
};
|
||||
const textRects: Record<string, DOMRect[]> = {
|
||||
a: normalizeTextRects(options.a.textRect),
|
||||
b: normalizeTextRects(options.b.textRect),
|
||||
};
|
||||
const { colors, clipPaths, positions, textRects, elementRects } = overlapBlockRecords({
|
||||
a: options.a,
|
||||
b: options.b,
|
||||
});
|
||||
|
||||
installOverlapStyles(colors, clipPaths);
|
||||
installOverlapGeometry(textRects);
|
||||
installOverlapStyles(colors, clipPaths, {}, positions);
|
||||
installOverlapGeometry(textRects, elementRects);
|
||||
installAuditScript();
|
||||
return runAudit();
|
||||
}
|
||||
@@ -2346,11 +2408,15 @@ function installOverlapStyles(
|
||||
colors: Record<string, string>,
|
||||
clipPaths: Record<string, string>,
|
||||
overflows: Record<string, string> = {},
|
||||
positions: Record<string, string> = {},
|
||||
): void {
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
|
||||
const id = (element as Element).id;
|
||||
return {
|
||||
display: "block",
|
||||
// Mirrors the real browser default, so only a scene that explicitly opts
|
||||
// into absolute/fixed takes the out-of-flow box measurement.
|
||||
position: positions[id] ?? "static",
|
||||
visibility: "visible",
|
||||
opacity: "1",
|
||||
color: colors[id] ?? "rgb(0, 0, 0)",
|
||||
|
||||
Reference in New Issue
Block a user