Files
hyperframes/packages/cli/src/utils/layoutAudit.ts
T
Xuanru Li 7f4eaeb568 feat(cli): coordinate-frame layout findings in check (#2354)
* 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.
2026-07-13 15:35:03 -07:00

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;
}