mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
The layout audit only reported boxes that overflow their container; text that fits perfectly but is painted over by a later sibling or overlay was never caught. Add a text_occluded check that sweeps a grid across each text box (three rows x nine columns) and, via elementFromPoint, flags text whose topmost element is an unrelated opaque element (raster content, background image, or a solid background at near-full opacity). Low-opacity overlays such as scrims and grain are exempt. Opt out of intentional layering with data-layout-allow-occlusion. The two *.browser.js audit scripts are added to the fallow entry list: they are injected by path via page.addScriptTag, so they have no import-graph referrer. Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
308 lines
8.8 KiB
TypeScript
308 lines
8.8 KiB
TypeScript
export interface LayoutRect {
|
|
left: number;
|
|
top: number;
|
|
right: number;
|
|
bottom: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export type LayoutOverflow = Partial<Record<"left" | "right" | "top" | "bottom", number>>;
|
|
|
|
export type LayoutIssueCode =
|
|
| "text_box_overflow"
|
|
| "clipped_text"
|
|
| "canvas_overflow"
|
|
| "container_overflow"
|
|
| "content_overlap"
|
|
| "text_occluded";
|
|
|
|
export type LayoutIssueSeverity = "error" | "warning" | "info";
|
|
|
|
export interface LayoutIssue {
|
|
code: LayoutIssueCode;
|
|
severity: LayoutIssueSeverity;
|
|
time: number;
|
|
firstSeen?: number;
|
|
lastSeen?: number;
|
|
occurrences?: number;
|
|
selector: string;
|
|
containerSelector?: string;
|
|
text?: string;
|
|
message: string;
|
|
rect: LayoutRect;
|
|
containerRect?: LayoutRect;
|
|
overflow?: LayoutOverflow;
|
|
fixHint?: string;
|
|
}
|
|
|
|
export interface LayoutSummary {
|
|
ok: boolean;
|
|
errorCount: number;
|
|
warningCount: number;
|
|
infoCount: number;
|
|
issueCount: number;
|
|
}
|
|
|
|
export interface LayoutSampleOptions {
|
|
duration: number;
|
|
samples: number;
|
|
at?: number[];
|
|
}
|
|
|
|
export function buildLayoutSampleTimes({ duration, samples, at }: LayoutSampleOptions): number[] {
|
|
if (at?.length) {
|
|
return uniqueSortedTimes(
|
|
at.filter(
|
|
(time) => Number.isFinite(time) && time >= 0 && (duration <= 0 || time <= duration),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (!Number.isFinite(duration) || duration <= 0 || samples <= 0) return [];
|
|
|
|
const count = Math.max(1, Math.floor(samples));
|
|
return Array.from({ length: count }, (_, index) => roundTime(((index + 0.5) / count) * duration));
|
|
}
|
|
|
|
export function computeOverflow(
|
|
subject: LayoutRect,
|
|
container: LayoutRect,
|
|
tolerance: number,
|
|
): LayoutOverflow | null {
|
|
const overflow: LayoutOverflow = {};
|
|
|
|
if (subject.left < container.left - tolerance) {
|
|
overflow.left = roundPx(container.left - subject.left);
|
|
}
|
|
if (subject.right > container.right + tolerance) {
|
|
overflow.right = roundPx(subject.right - container.right);
|
|
}
|
|
if (subject.top < container.top - tolerance) {
|
|
overflow.top = roundPx(container.top - subject.top);
|
|
}
|
|
if (subject.bottom > container.bottom + tolerance) {
|
|
overflow.bottom = roundPx(subject.bottom - container.bottom);
|
|
}
|
|
|
|
return Object.keys(overflow).length > 0 ? overflow : null;
|
|
}
|
|
|
|
export function summarizeLayoutIssues(issues: LayoutIssue[]): LayoutSummary {
|
|
const errorCount = issues.filter((issue) => issue.severity === "error").length;
|
|
const warningCount = issues.filter((issue) => issue.severity === "warning").length;
|
|
const infoCount = issues.filter((issue) => issue.severity === "info").length;
|
|
|
|
return {
|
|
ok: errorCount === 0,
|
|
errorCount,
|
|
warningCount,
|
|
infoCount,
|
|
issueCount: issues.length,
|
|
};
|
|
}
|
|
|
|
export function formatLayoutIssue(issue: LayoutIssue): string {
|
|
const timeLabel =
|
|
issue.occurrences && issue.occurrences > 1
|
|
? `t=${formatNumber(issue.firstSeen ?? issue.time)}-${formatNumber(issue.lastSeen ?? issue.time)}s (${issue.occurrences} samples)`
|
|
: `t=${formatNumber(issue.time)}s`;
|
|
const parts = [
|
|
timeLabel,
|
|
issue.code,
|
|
issue.selector,
|
|
issue.containerSelector ? `inside ${issue.containerSelector}` : "",
|
|
issue.overflow ? `overflowed ${formatOverflow(issue.overflow)}` : "",
|
|
issue.text ? quoteText(issue.text) : "",
|
|
].filter(Boolean);
|
|
|
|
const line = `${parts.join(" ")} — ${issue.message}`;
|
|
return issue.fixHint ? `${line}\n Fix: ${issue.fixHint}` : line;
|
|
}
|
|
|
|
export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
|
|
const seen = new Set<string>();
|
|
const result: LayoutIssue[] = [];
|
|
|
|
for (const issue of issues) {
|
|
const key = [
|
|
issue.code,
|
|
issue.severity,
|
|
issue.time.toFixed(3),
|
|
issue.selector,
|
|
issue.containerSelector ?? "",
|
|
issue.text ?? "",
|
|
issue.overflow ? formatOverflow(issue.overflow) : "",
|
|
].join("|");
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(issue);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
issue: LayoutIssue;
|
|
firstSeen: number;
|
|
lastSeen: number;
|
|
occurrences: number;
|
|
}
|
|
>();
|
|
|
|
for (const issue of issues) {
|
|
const key = staticIssueKey(issue);
|
|
const existing = groups.get(key);
|
|
if (!existing) {
|
|
groups.set(key, {
|
|
issue,
|
|
firstSeen: issue.time,
|
|
lastSeen: issue.time,
|
|
occurrences: 1,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
existing.firstSeen = Math.min(existing.firstSeen, issue.time);
|
|
existing.lastSeen = Math.max(existing.lastSeen, issue.time);
|
|
existing.occurrences += 1;
|
|
}
|
|
|
|
return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => ({
|
|
...issue,
|
|
time: firstSeen,
|
|
firstSeen,
|
|
lastSeen,
|
|
occurrences,
|
|
}));
|
|
}
|
|
|
|
export function limitLayoutIssues(
|
|
issues: LayoutIssue[],
|
|
maxIssues: number,
|
|
): { issues: LayoutIssue[]; totalIssueCount: number; truncated: boolean } {
|
|
const limit = Math.max(1, Math.floor(maxIssues));
|
|
const sortedIssues = [...issues].sort((a, b) => {
|
|
const severityDelta = severityRank(a.severity) - severityRank(b.severity);
|
|
if (severityDelta !== 0) return severityDelta;
|
|
return a.time - b.time;
|
|
});
|
|
return {
|
|
issues: sortedIssues.slice(0, limit),
|
|
totalIssueCount: issues.length,
|
|
truncated: issues.length > limit,
|
|
};
|
|
}
|
|
|
|
function severityRank(severity: LayoutIssueSeverity): number {
|
|
if (severity === "error") return 0;
|
|
if (severity === "warning") return 1;
|
|
return 2;
|
|
}
|
|
|
|
function staticIssueKey(issue: LayoutIssue): string {
|
|
return [
|
|
issue.code,
|
|
issue.severity,
|
|
issue.selector,
|
|
issue.containerSelector ?? "",
|
|
issue.text ?? "",
|
|
issue.overflow ? formatOverflow(issue.overflow) : "",
|
|
].join("|");
|
|
}
|
|
|
|
function uniqueSortedTimes(times: number[]): number[] {
|
|
const rounded = times.map(roundTime);
|
|
return [...new Set(rounded)].sort((a, b) => a - b);
|
|
}
|
|
|
|
export interface TransitionSampleOptions {
|
|
duration: number;
|
|
boundaries: number[];
|
|
/** Optional hard limit on the returned sample count. No limit when absent. */
|
|
cap?: number;
|
|
}
|
|
|
|
export interface TransitionSamples {
|
|
times: number[];
|
|
/** Sample times omitted because of `cap`. Always 0 when no cap is given. */
|
|
dropped: number;
|
|
}
|
|
|
|
/**
|
|
* Build sample times from tween start/end boundaries: the boundaries
|
|
* themselves plus the midpoint of every segment between consecutive
|
|
* boundaries. Boundary frames are where transient overlaps live (#1380), but
|
|
* sampling exactly at a boundary can land on an element at opacity 0 — the
|
|
* segment midpoints catch the window where both sides of a transition are
|
|
* partially visible. Every collected boundary is sampled unless the caller
|
|
* passes an explicit `cap`, in which case the result is an evenly-strided
|
|
* subset and `dropped` reports how many sample times were omitted.
|
|
*/
|
|
export function buildTransitionSampleTimes({
|
|
duration,
|
|
boundaries,
|
|
cap,
|
|
}: TransitionSampleOptions): TransitionSamples {
|
|
if (!Number.isFinite(duration) || duration <= 0) return { times: [], dropped: 0 };
|
|
const inRange = uniqueSortedTimes(
|
|
boundaries.filter((time) => Number.isFinite(time) && time >= 0 && time <= duration),
|
|
);
|
|
const withMidpoints = [...inRange];
|
|
for (let i = 0; i < inRange.length - 1; i++) {
|
|
const current = inRange[i];
|
|
const next = inRange[i + 1];
|
|
if (current === undefined || next === undefined) continue;
|
|
withMidpoints.push(roundTime((current + next) / 2));
|
|
}
|
|
const merged = uniqueSortedTimes(withMidpoints);
|
|
if (cap === undefined || merged.length <= Math.max(2, cap)) {
|
|
return { times: merged, dropped: 0 };
|
|
}
|
|
const limit = Math.max(2, cap);
|
|
const strided: number[] = [];
|
|
for (let i = 0; i < limit; i++) {
|
|
const pick = merged[Math.floor((i * (merged.length - 1)) / (limit - 1))];
|
|
if (pick !== undefined) strided.push(pick);
|
|
}
|
|
const times = uniqueSortedTimes(strided);
|
|
return { times, dropped: merged.length - times.length };
|
|
}
|
|
|
|
/** Merge sample-time lists into one deduplicated ascending list. */
|
|
export function mergeSampleTimes(...lists: number[][]): number[] {
|
|
return uniqueSortedTimes(lists.flat());
|
|
}
|
|
|
|
function formatOverflow(overflow: LayoutOverflow): string {
|
|
return (["left", "right", "top", "bottom"] as const)
|
|
.flatMap((side) => {
|
|
const value = overflow[side];
|
|
return value == null ? [] : `${side} ${formatNumber(value)}px`;
|
|
})
|
|
.join(", ");
|
|
}
|
|
|
|
function quoteText(text: string): string {
|
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
const truncated = normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
|
return `"${truncated}"`;
|
|
}
|
|
|
|
function formatNumber(value: number): string {
|
|
return Number.isInteger(value)
|
|
? String(value)
|
|
: value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
|
}
|
|
|
|
function roundTime(value: number): number {
|
|
return Math.round(value * 1000) / 1000;
|
|
}
|
|
|
|
function roundPx(value: number): number {
|
|
return Math.round(value * 100) / 100;
|
|
}
|