mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
* feat(cli): coordinate-frame layout findings in check Four production compositions shipped with 100-600px layout drift, each a different coordinate-frame confusion the check graded info or missed entirely: viewport pixels written as container left/top, gsap x/y treated as absolute position, a -350px margin fighting flex centering, and stage-relative path coords drawn into a nested SVG. Three new layout findings close the class: - positioned_out_of_parent: an absolute/fixed element rendering mostly outside its positioning ancestor (warning) — the parent needs no overflow clipping, which is what let container_overflow miss it. - box_out_of_canvas: a painted panel breaching the canvas (warning) — text is canvas_overflow's, media is frame_out_of_frame's, painted boxes were nobody's. - connector_detached: a connector path whose endpoints land far from every anchorable element (warning) — measured coordinates drawn into an SVG with a different origin. canvas_overflow additionally promotes from info to warning when held across samples AND the breach exceeds 5% of the canvas. All three are persistence-tiered and respect data-layout-allow-overflow. Verified against the four incident compositions: every one now surfaces its drift as held warnings (previously: info or silence). * fix(cli): harden coordinate-frame findings against review false positives Reworks all three findings after two-lens review (adversarial FP hunt in real Chrome + maintainer pass): - escaped_container (was positioned_out_of_parent): uses offsetParent (transform-aware, skips fixed-as-canvas), exempts fully-detached callouts within an attachment allowance while still flagging touching-but-mostly-outside drift. - panel_out_of_canvas (was box_out_of_canvas): paint alone qualifies (flat solid panels were a false negative), fully off-canvas rects are parked entrances and stay silent, pointer-events:none marks decorative layers, hero-sized breaches warn while small bleeds stay info. - connector_detached: endpoints via getPointAtLength + getScreenCTM (viewBox, preserveAspectRatio, group transforms, every command type), defs/marker/clipPath subtrees skipped, word-boundary connector naming, containment tier limited to opaque non-ancestor targets (a text-bearing wrapper contains its own diagram's endpoints). - canvas_overflow promotion requires partial visibility — a fully off-canvas rect is a parked entrance, not drift. Verified: the four incident compositions still surface their drift as held warnings; the review's false-positive repros (fixed HUD, callout, parked entrance, corner bleed, marker arrowheads, g-transform and viewBox-scaled connectors) are clean at warning level. Docs and the CLI skill reference now describe the coordinate-frame findings. * fix(cli): panel ownership is geometric — direct-text panels were a silent false negative A painted panel whose direct text stays in-bounds while its box breaches the canvas produced neither finding: canvas_overflow measures the text range and panel_out_of_canvas skipped every own-text element. Skip the panel finding only when the element's own text ALSO breaches (that geometry belongs to canvas_overflow); pin the message/fixHint wording of all three findings with positive assertions; document the SVG-internal anchor blind spot. * fix(cli): classify panel decoration by paint kind, not pointer-events pointer-events:none exempted the framed-painting incident's gold frame layers — hero content that happens to disable hit-testing. Decoration is now gradient-only paint (spotlights, textures, vignettes); url() images, solid fills and borders are content regardless of pointer-events. * fix(cli): add fixHint to the test-local AuditIssue shape * fix(cli): gradient stops decide content vs decoration; ownership matches canvas_overflow's tolerance A gradient with any solid stop (alpha >= 0.6) is content — heroes and cards painted with linear-gradient were invisible under the blanket gradient exemption; all-translucent stops (spotlights, vignettes) stay decoration. The text-ownership check now uses the audit tolerance that canvas_overflow itself fires at, making the contract strict-mutex: any text breach past that tolerance cedes the element, so a shallow 20px text breach no longer double-reports.
458 lines
16 KiB
TypeScript
458 lines
16 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"
|
|
| "text_not_painted"
|
|
| "caption_zone_collision"
|
|
| "frame_out_of_frame"
|
|
// Coordinate-frame findings — geometry computed in one frame, rendered in another.
|
|
| "escaped_container"
|
|
| "panel_out_of_canvas"
|
|
| "connector_detached"
|
|
// Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample
|
|
// geometry observation; never persistence-tiered (see `applyPersistenceTier`).
|
|
| "sweep_static"
|
|
// Motion-verification findings (#1437) — evaluated against the seeked timeline.
|
|
| "motion_appears_late"
|
|
| "motion_out_of_order"
|
|
| "motion_off_frame"
|
|
| "motion_frozen"
|
|
| "motion_selector_missing"
|
|
| "motion_selector_ambiguous";
|
|
|
|
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;
|
|
/** `text_occluded` only: approximate fraction (0-1) of the occlusion probe
|
|
* grid that hit an opaque occluder — see layout-audit.browser.js. */
|
|
coveredFraction?: number;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Whether a computed `overflow*` value clips its box. Mirrors the rule the
|
|
* browser audit (layout-audit.browser.js) uses to decide that text spilling
|
|
* past such an ancestor is intentionally masked (odometer/ticker reels) rather
|
|
* than a `text_box_overflow` defect. Kept here as the one unit-testable seam of
|
|
* that suppression: only `visible` (and the `clip visible` no-op) must NOT clip
|
|
* — every clipping value must, or real masked overflow gets reported as a bug.
|
|
*/
|
|
export function overflowValueClips(value: string | null | undefined): boolean {
|
|
return !!value && value !== "visible" && value !== "clip visible";
|
|
}
|
|
|
|
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) : "",
|
|
framePositionKey(issue),
|
|
].join("|");
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(issue);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Persistence-tier thresholds (#U10, adapted from Adam Rosler's visual-linter
|
|
// design). The approach doc frames these as held-duration floors — ignore
|
|
// under ~250ms, re-promote content_overlap at >= ~500ms — measured against
|
|
// the SAME firstSeen/lastSeen span this collapse step already tracks. At the
|
|
// default 9-sample grid over a multi-second composition, a single collapsed
|
|
// occurrence is held 0ms (one entrance/exit transient sample) and two
|
|
// collapsed occurrences are already >= one sample-to-sample gap, which is
|
|
// well past 500ms — so "held under 250ms" reduces to `occurrences <= 1` and
|
|
// "held >= 500ms" reduces to `occurrences >= 2`. Tiering below is written in
|
|
// those sample-count terms (the mapping the approach doc asks to document),
|
|
// with the literal ms span (CONTENT_OVERLAP_HELD_ERROR_MS) kept as a fallback
|
|
// for callers whose samples really are spaced close enough together for the
|
|
// ms floor to matter on its own (dense `--at`/`--at-transitions` runs). The
|
|
// ~250ms ignore floor needs no separate constant — see the occurrences <= 1
|
|
// branch below.
|
|
const CONTENT_OVERLAP_HELD_ERROR_MS = 500;
|
|
const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2;
|
|
|
|
// Tiering only applies to layout-audit.browser.js's own per-sample seek-grid
|
|
// findings — the ones this collapse step's firstSeen/lastSeen span was built
|
|
// to describe. `caption_zone_collision`/`frame_out_of_frame` (a different
|
|
// script, U3) and the `motion_*`/`sweep_static` codes (evaluated once over
|
|
// the whole run, not per grid sample) already carry their own singular
|
|
// dedupe/severity semantics; re-interpreting their occurrence count as a
|
|
// held-duration signal would misread it.
|
|
const PERSISTENCE_TIERED_CODES: ReadonlySet<LayoutIssueCode> = new Set([
|
|
"text_box_overflow",
|
|
"clipped_text",
|
|
"canvas_overflow",
|
|
"container_overflow",
|
|
"content_overlap",
|
|
"text_occluded",
|
|
"escaped_container",
|
|
"panel_out_of_canvas",
|
|
"connector_detached",
|
|
]);
|
|
|
|
export function collapseStaticLayoutIssues(
|
|
issues: LayoutIssue[],
|
|
totalSampleCount?: number,
|
|
): 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;
|
|
}
|
|
|
|
// A run that only ever sampled one point in time can't distinguish a
|
|
// transient from a persistent finding — skip tiering entirely rather than
|
|
// guess (see `applyPersistenceTier`).
|
|
const sampleCount = totalSampleCount ?? new Set(issues.map((issue) => issue.time)).size;
|
|
const multiSampleRun = sampleCount > 1;
|
|
|
|
return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) =>
|
|
applyPersistenceTier(
|
|
{ ...issue, time: firstSeen, firstSeen, lastSeen, occurrences },
|
|
multiSampleRun,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Held-duration severity tiering (#U10). A finding observed at only one
|
|
* sample among several (held 0ms) is an entrance/exit transient, not a held
|
|
* defect — demote to info so it stays in the data (verbose/--json output)
|
|
* without gating the run. Two codes re-promote once held: `content_overlap`
|
|
* warning->error when the collision is sustained rather than a crossfade blip
|
|
* (resolves the TODO in layout-audit.browser.js's `overlapIssue`), and
|
|
* `canvas_overflow` info->warning when the breach is held, canvas-scale
|
|
* (>= 5% of the short edge) AND partially visible — a fully off-canvas rect
|
|
* is a parked entrance, not drift. Codes without a promotion rule are left
|
|
* untouched when held — persistence, not the code, decides their tier.
|
|
*/
|
|
function applyPersistenceTier(issue: LayoutIssue, multiSampleRun: boolean): LayoutIssue {
|
|
if (!multiSampleRun) return issue;
|
|
if (!PERSISTENCE_TIERED_CODES.has(issue.code)) return issue;
|
|
|
|
const occurrences = issue.occurrences ?? 1;
|
|
// A single collapsed occurrence is held 0ms by construction (firstSeen ===
|
|
// lastSeen) — always under the ignore floor, so occurrences <= 1 is a
|
|
// complete (not approximate) test for "held under 250ms".
|
|
if (occurrences <= 1) {
|
|
return { ...issue, severity: "info" };
|
|
}
|
|
if (issue.code === "content_overlap" && isContentOverlapHeldLongEnough(issue, occurrences)) {
|
|
return { ...issue, severity: "error" };
|
|
}
|
|
if (issue.code === "canvas_overflow" && isCanvasBreachHeldLarge(issue, occurrences)) {
|
|
return { ...issue, severity: "warning" };
|
|
}
|
|
return issue;
|
|
}
|
|
|
|
// A held, canvas-scale, PARTIALLY visible breach is drift; a fully off-canvas rect is a parked entrance.
|
|
function isCanvasBreachHeldLarge(issue: LayoutIssue, occurrences: number): boolean {
|
|
if (
|
|
occurrences < HELD_ACROSS_SAMPLES_MIN_OCCURRENCES ||
|
|
!issue.overflow ||
|
|
!issue.containerRect
|
|
) {
|
|
return false;
|
|
}
|
|
const breach = Math.max(
|
|
...Object.values(issue.overflow).filter((value) => typeof value === "number"),
|
|
);
|
|
if (breach < Math.min(issue.containerRect.width, issue.containerRect.height) * 0.05) return false;
|
|
const container = issue.containerRect;
|
|
const overlapX =
|
|
Math.min(issue.rect.right, container.right) - Math.max(issue.rect.left, container.left);
|
|
const overlapY =
|
|
Math.min(issue.rect.bottom, container.bottom) - Math.max(issue.rect.top, container.top);
|
|
return overlapX > 0 && overlapY > 0;
|
|
}
|
|
|
|
// Split out of applyPersistenceTier so the two independent "held long enough"
|
|
// signals (sample count vs. wall-clock span) read as one boolean question
|
|
// instead of adding a third compound branch to the tiering ladder above.
|
|
function isContentOverlapHeldLongEnough(issue: LayoutIssue, occurrences: number): boolean {
|
|
if (occurrences >= HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return true;
|
|
const firstSeen = issue.firstSeen ?? issue.time;
|
|
const lastSeen = issue.lastSeen ?? issue.time;
|
|
const heldMs = (lastSeen - firstSeen) * 1000;
|
|
return heldMs >= CONTENT_OVERLAP_HELD_ERROR_MS;
|
|
}
|
|
|
|
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) : "",
|
|
framePositionKey(issue),
|
|
].join("|");
|
|
}
|
|
|
|
function framePositionKey(issue: LayoutIssue): string {
|
|
// connector_detached shares it: id-less paths collapse to one selector, so distinct lines need geometry in the key.
|
|
return issue.code === "frame_out_of_frame" || issue.code === "connector_detached"
|
|
? `${Math.round(issue.rect.left)},${Math.round(issue.rect.top)}`
|
|
: "";
|
|
}
|
|
|
|
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;
|
|
}
|