mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(lint): add rotation_pivot_drift layout check (#2741)
## What New cross-sample layout check `rotation_pivot_drift` — flags a rotating element that should spin **in place** but pivots about the **wrong point** (e.g. a wheel whose spokes use a hardcoded px `transformOrigin` instead of `svgOrigin`/`%`, so they swing off-center while every existing check still passes). Motivating prod case: a portrait ad's spoked-wheel whose `#spokes` rotated about `transformOrigin:"250px 250px"` in a resized 460px container — spokes detached from the hub, shipped clean because no rule inspects rotation. ## How - `layout-audit.browser.js`: `window.__hyperframesRotationSample()` reports each visible transformed element's bbox center + decoded rotation angle per layout sample. Skips `[data-layout-allow-orbit]`. - `checkPipeline.ts`: accumulates samples across the seek grid; `detectRotationPivotDrift()` (modeled on `detectSweepStatic`) flags an element that (a) actually spins (angle spread > 20° over ≥3 samples), (b) is size-stable (bbox width ratio ≤ 1.6), and (c) whose bbox **center** drifts > `max(10% of its size, 2% of min viewport dim)`. Emits `warning`; not persistence-tiered (not demoted to info). ## FP guards Real rotation required, ≥3 samples, size stability, `data-layout-allow-orbit` exemption, min area ~2500px². Center-drift (not bbox size) is the discriminator, so a correctly-centered spinner reads drift ≈ 0. ## Validation (`check --json`) | Fixture | Expected | Result | |---|---|---| | off-transformOrigin spoked wheel | fire | **fired — 109px drift on `#spokes`** | | non-spinning comps (node diagram, device tree) | clean | clean, no FP | | correctly-centered spinner (`svgOrigin`) | clean | clean (spins 162°, drift 0) | | `data-layout-allow-orbit` off-origin spinner | clean | clean (exempt) | | off-`svgOrigin` control, no opt-out | fire | fired — 251px drift | No false positives. `tsc --noEmit` clean, `oxlint` clean, `check.test.ts` + `layout-audit.browser.test.ts` = 112/112 pass. ## Note `ROTATION_MAX_SIZE_RATIO` is 1.6 (not 1.3): a rotating anisotropic shape's axis-aligned bbox inherently oscillates (8-spoke star ~1.32×, square 1.41×), so a tighter ratio rejects legitimate targets. Center-drift stays the real discriminator; thin swinging bars are excluded. Follow-up: a `detectRotationPivotDrift` unit test via the fake driver's `collectRotationSample` (mirroring the sweep_static tests). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -149,6 +149,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
|
||||
seek: vi.fn(async (_time: number) => undefined),
|
||||
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
|
||||
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
|
||||
collectRotationSample: vi.fn(async (_time: number) => []),
|
||||
collectGeometryCandidates: vi.fn(async () => []),
|
||||
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
|
||||
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
|
||||
|
||||
@@ -1480,4 +1480,55 @@
|
||||
}
|
||||
return parts.join("|");
|
||||
};
|
||||
|
||||
// Rotation-pivot sampling (rotation_pivot_drift). Per sample, report every
|
||||
// rotatable candidate's bbox center, size, and current rotation angle. Node
|
||||
// accumulates these across the seek grid and, after the run, flags any
|
||||
// element that spins (angle varies) while its bbox CENTER drifts — the
|
||||
// signature of a wrong transformOrigin/svgOrigin (spokes swinging off-axis
|
||||
// instead of spinning in place). Single frame can't tell spin from pivot
|
||||
// drift, so this is a cross-sample finder, not a per-sample one.
|
||||
function rotationAngleDeg(transform) {
|
||||
if (!transform || transform === "none") return null;
|
||||
const match = transform.match(/matrix(3d)?\(([^)]+)\)/);
|
||||
if (!match) return null;
|
||||
const values = match[2].split(",").map((part) => Number.parseFloat(part));
|
||||
// matrix(a,b,c,d,e,f) → a=values[0], b=values[1]. matrix3d shares the same
|
||||
// leading two entries for the in-plane 2D rotation component.
|
||||
const a = values[0];
|
||||
const b = values[1];
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
|
||||
return (Math.atan2(b, a) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
window.__hyperframesRotationSample = function collectRotationSample() {
|
||||
const root =
|
||||
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
||||
document.querySelector("[data-composition-id]") ||
|
||||
document.body;
|
||||
const samples = [];
|
||||
// Cap the candidate set so a pathological composition can't blow up the
|
||||
// per-sample payload; transformed elements above a minimum area only.
|
||||
const CANDIDATE_CAP = 200;
|
||||
for (const element of Array.from(root.querySelectorAll("*"))) {
|
||||
if (samples.length >= CANDIDATE_CAP) break;
|
||||
// Intended orbits/satellites opt out — their bbox center is SUPPOSED to
|
||||
// travel, so a drift finding there is a false positive.
|
||||
if (element.closest("[data-layout-allow-orbit]")) continue;
|
||||
if (!isVisibleElement(element, 0.05)) continue;
|
||||
const angle = rotationAngleDeg(getComputedStyle(element).transform);
|
||||
if (angle === null) continue; // identity / untransformed — not a candidate
|
||||
const box = element.getBoundingClientRect();
|
||||
if (box.width * box.height <= 400) continue;
|
||||
samples.push({
|
||||
selector: selectorFor(element),
|
||||
cx: round(box.left + box.width / 2),
|
||||
cy: round(box.top + box.height / 2),
|
||||
w: round(box.width),
|
||||
h: round(box.height),
|
||||
angle: round(angle),
|
||||
});
|
||||
}
|
||||
return samples;
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
ContrastCapture,
|
||||
GeometryCandidateRequest,
|
||||
MotionSpecResolution,
|
||||
RotationSample,
|
||||
RunAuditGrid,
|
||||
} from "./checkTypes.js";
|
||||
import type { ProjectDir } from "./project.js";
|
||||
@@ -347,6 +348,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
|
||||
},
|
||||
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
|
||||
collectLayoutGeometry: () => collectLayoutGeometry(page),
|
||||
collectRotationSample: (time) => collectRotationSample(page, time),
|
||||
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
|
||||
collectMotionFrame: (time, selectors, scopes) =>
|
||||
collectMotionFrame(page, time, selectors, scopes),
|
||||
@@ -468,6 +470,30 @@ async function collectLayoutGeometry(page: Page): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function collectRotationSample(page: Page, time: number): Promise<RotationSample[]> {
|
||||
const raw = await page.evaluate(() => {
|
||||
const sample = Reflect.get(window, "__hyperframesRotationSample");
|
||||
if (typeof sample !== "function") return [];
|
||||
const result = Reflect.apply(sample, window, []);
|
||||
return Array.isArray(result) ? result : [];
|
||||
});
|
||||
return raw.flatMap((value) => parseRotationSample(value, time));
|
||||
}
|
||||
|
||||
function parseRotationSample(value: unknown, time: number): RotationSample[] {
|
||||
if (!isRecord(value)) return [];
|
||||
const selector = stringValue(value, "selector");
|
||||
const cx = numberValue(value, "cx");
|
||||
const cy = numberValue(value, "cy");
|
||||
const w = numberValue(value, "w");
|
||||
const h = numberValue(value, "h");
|
||||
const angle = numberValue(value, "angle");
|
||||
if (!selector || cx === null || cy === null || w === null || h === null || angle === null) {
|
||||
return [];
|
||||
}
|
||||
return [{ time, selector, cx, cy, w, h, angle }];
|
||||
}
|
||||
|
||||
async function collectGeometryCandidates(
|
||||
page: Page,
|
||||
time: number,
|
||||
@@ -1051,6 +1077,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
|
||||
"escaped_container",
|
||||
"panel_out_of_canvas",
|
||||
"connector_detached",
|
||||
"rotation_pivot_drift",
|
||||
"motion_appears_late",
|
||||
"motion_out_of_order",
|
||||
"motion_off_frame",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { detectRotationPivotDrift } from "./checkPipeline.js";
|
||||
import type { RotationSample } from "./checkTypes.js";
|
||||
|
||||
const CANVAS = { width: 1000, height: 1000 };
|
||||
|
||||
/** One rotation sample; defaults describe a large, size-stable element. */
|
||||
function sample(overrides: Partial<RotationSample> = {}): RotationSample {
|
||||
return { time: 0, selector: "#spokes", cx: 250, cy: 250, w: 200, h: 200, angle: 0, ...overrides };
|
||||
}
|
||||
|
||||
/** A group that SHOULD fire: spins (0→90→180), size-stable, sizable, and its
|
||||
* bbox center travels 50px — the wrong-pivot signature. threshold here is
|
||||
* max(0.1*200, 0.02*1000) = 20px, so 50px drift clears it. */
|
||||
function driftingSpinner(): RotationSample[] {
|
||||
return [
|
||||
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 90, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: 180, cx: 250, cy: 300 }),
|
||||
];
|
||||
}
|
||||
|
||||
describe("detectRotationPivotDrift", () => {
|
||||
it("fires on a spinning, size-stable element whose bbox center drifts past threshold", () => {
|
||||
const findings = detectRotationPivotDrift(driftingSpinner(), CANVAS);
|
||||
expect(findings).toHaveLength(1);
|
||||
const [f] = findings;
|
||||
expect(f?.code).toBe("rotation_pivot_drift");
|
||||
expect(f?.severity).toBe("warning");
|
||||
expect(f?.selector).toBe("#spokes");
|
||||
expect(f?.message).toContain("50px");
|
||||
expect(f?.fixHint).toContain("transformOrigin");
|
||||
});
|
||||
|
||||
// Stage 1 — sample count.
|
||||
it("does not fire with fewer than the minimum samples", () => {
|
||||
const group = driftingSpinner().slice(0, 2);
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Stage 2 — real spin. A translating-but-not-spinning element is not our bug.
|
||||
it("does not fire when the element barely rotates (fixed tilt, not spinning)", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 2, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: 4, cx: 250, cy: 300 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Stage 3 — size stability, WIDTH axis (scale/entrance, not pivot drift).
|
||||
it("does not fire when width scales across samples", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, w: 100, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 90, w: 200, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: 180, w: 300, cx: 250, cy: 300 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Stage 3 — size stability, HEIGHT axis. Regression for the width-only guard:
|
||||
// fixed width, top-anchored height growth (top=100 → cy = 100 + h/2) drifts
|
||||
// the AABB center 50px on its own. Must NOT be reported as pivot drift.
|
||||
it("does not fire when height scales (top-anchored) even though the AABB center moves", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, w: 100, h: 50, cx: 250, cy: 125 }),
|
||||
sample({ time: 1, angle: 90, w: 100, h: 100, cx: 250, cy: 150 }),
|
||||
sample({ time: 2, angle: 180, w: 100, h: 150, cx: 250, cy: 175 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not fire when a sample has a degenerate zero dimension", () => {
|
||||
const group = driftingSpinner().map((s, i) => (i === 0 ? { ...s, w: 0 } : s));
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Stage 4 — sizable. Tiny decorative spinners are ignored (area < 2500px²).
|
||||
it("does not fire on a tiny element below the median-area floor", () => {
|
||||
const group = driftingSpinner().map((s) => ({ ...s, w: 40, h: 40 }));
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Stage 5 — center drift. A correctly-centered spinner holds its bbox center.
|
||||
it("does not fire on a spinner whose bbox center stays put", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0 }),
|
||||
sample({ time: 1, angle: 120 }),
|
||||
sample({ time: 2, angle: 240 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses the viewport floor when the element is small relative to a large canvas", () => {
|
||||
// medianSize=200 → sizeFloor=20; viewportFloor on a 3000px canvas = 60.
|
||||
// A 40px drift is below 60 → clean; the same group fired on CANVAS above.
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 90, cx: 250, cy: 270 }),
|
||||
sample({ time: 2, angle: 180, cx: 250, cy: 290 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, { width: 3000, height: 3000 })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports each drifting selector independently and leaves clean ones alone", () => {
|
||||
const clean = driftingSpinner().map((s) => ({ ...s, selector: "#hub", cx: 500, cy: 500 }));
|
||||
const findings = detectRotationPivotDrift([...driftingSpinner(), ...clean], CANVAS);
|
||||
expect(findings.map((f) => f.selector)).toEqual(["#spokes"]);
|
||||
});
|
||||
|
||||
// Wrap-aware angle spread across the ±180° discontinuity. A wobble between
|
||||
// -175° and 175° is ~10° of travel, not ~350°; naive abs-diff would misread it
|
||||
// as a fast spin and (with a drifting center) fire falsely.
|
||||
it("does not treat a ±180° boundary wobble as spinning", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: -175, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 175, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: -172, cx: 250, cy: 300 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still detects a real spin that crosses the ±180° boundary", () => {
|
||||
// 170° → -100° → -10° is ~270° of genuine travel across the seam.
|
||||
const group = [
|
||||
sample({ time: 0, angle: 170, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: -100, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: -10, cx: 250, cy: 300 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty sample set", () => {
|
||||
expect(detectRotationPivotDrift([], CANVAS)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
ContrastAuditEntry,
|
||||
GeometryCandidateRequest,
|
||||
MotionSpecResolution,
|
||||
RotationSample,
|
||||
} from "./checkTypes.js";
|
||||
|
||||
export type {
|
||||
@@ -189,6 +190,9 @@ interface GridSamples {
|
||||
contrastMs: number;
|
||||
/** One geometry+opacity fingerprint per layout sample (#U10 frozen-sweep guard). */
|
||||
geometrySignatures: string[];
|
||||
/** Every rotatable element's geometry at each layout sample; grouped by
|
||||
* selector after the run to detect rotation_pivot_drift. */
|
||||
rotationSamples: RotationSample[];
|
||||
}
|
||||
|
||||
interface GeometrySeen {
|
||||
@@ -360,6 +364,7 @@ async function collectGridSamples(
|
||||
screenshots: [],
|
||||
contrastMs: 0,
|
||||
geometrySignatures: [],
|
||||
rotationSamples: [],
|
||||
};
|
||||
for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) {
|
||||
await driver.seek(time);
|
||||
@@ -372,6 +377,7 @@ async function collectGridSamples(
|
||||
collected.layoutIssues.push(...layoutIssues);
|
||||
issuesAtTime.push(...layoutIssues);
|
||||
collected.geometrySignatures.push(await driver.collectLayoutGeometry());
|
||||
collected.rotationSamples.push(...(await driver.collectRotationSample(time)));
|
||||
}
|
||||
if (canvas) {
|
||||
const geometryIssues = await collectGeometryAt(
|
||||
@@ -455,6 +461,185 @@ function detectSweepStatic(
|
||||
];
|
||||
}
|
||||
|
||||
// rotation_pivot_drift thresholds. A spinning element's bbox center should be
|
||||
// fixed; drift beyond this signals a wrong pivot (transformOrigin/svgOrigin).
|
||||
const ROTATION_MIN_SAMPLES = 3;
|
||||
// Degrees of angle spread that count as "actually spinning" (vs a static tilt).
|
||||
const ROTATION_MIN_ANGLE_SPREAD_DEG = 20;
|
||||
// Max bbox-width ratio across samples — above this it is scaling/entrancing,
|
||||
// not spinning in place. A rigid anisotropic shape's axis-aligned bbox
|
||||
// oscillates under rotation on its own (a plain square already swings 1.41x
|
||||
// between flat and 45deg), so this must sit above that; 1.6 admits rotating
|
||||
// squares/mild rectangles while still excluding gross scale/entrance growth
|
||||
// and thin bars/lines whose bbox swings many-fold (e.g. a rotating reference
|
||||
// arm). The bbox-CENTER drift below is the real spin-in-place discriminator.
|
||||
const ROTATION_MAX_SIZE_RATIO = 1.6;
|
||||
// Skip tiny decorative spinners; only sizable rotating figures matter.
|
||||
const ROTATION_MIN_MEDIAN_AREA_PX = 2500;
|
||||
const ROTATION_DRIFT_SIZE_FRACTION = 0.1;
|
||||
const ROTATION_DRIFT_VIEWPORT_FRACTION = 0.02;
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const upper = sorted[mid] ?? 0;
|
||||
return sorted.length % 2 === 0 ? ((sorted[mid - 1] ?? 0) + upper) / 2 : upper;
|
||||
}
|
||||
|
||||
/** Widest angular gap between any two samples, wrap-aware (0/360 continuity). */
|
||||
function maxAngleSpread(angles: number[]): number {
|
||||
let max = 0;
|
||||
for (let i = 0; i < angles.length; i++) {
|
||||
const a = angles[i];
|
||||
if (a === undefined) continue;
|
||||
for (let j = i + 1; j < angles.length; j++) {
|
||||
const b = angles[j];
|
||||
if (b === undefined) continue;
|
||||
const raw = Math.abs(a - b) % 360;
|
||||
max = Math.max(max, Math.min(raw, 360 - raw));
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Largest distance between any two bbox centers across the samples. */
|
||||
function maxCenterDrift(samples: RotationSample[]): number {
|
||||
let max = 0;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const first = samples[i];
|
||||
if (!first) continue;
|
||||
for (let j = i + 1; j < samples.length; j++) {
|
||||
const second = samples[j];
|
||||
if (!second) continue;
|
||||
max = Math.max(max, Math.hypot(first.cx - second.cx, first.cy - second.cy));
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function rotationDriftFinding(
|
||||
samples: RotationSample[],
|
||||
drift: number,
|
||||
): AnchoredLayoutIssue | null {
|
||||
const last = samples[samples.length - 1];
|
||||
if (!last) return null;
|
||||
const rect: LayoutRect = {
|
||||
left: last.cx - last.w / 2,
|
||||
top: last.cy - last.h / 2,
|
||||
right: last.cx + last.w / 2,
|
||||
bottom: last.cy + last.h / 2,
|
||||
width: last.w,
|
||||
height: last.h,
|
||||
};
|
||||
return {
|
||||
code: "rotation_pivot_drift",
|
||||
// EF promotes this to error separately; keep it a warning here.
|
||||
severity: "warning",
|
||||
time: last.time,
|
||||
selector: last.selector,
|
||||
dataAttributes: {},
|
||||
sourceFile: "index.html",
|
||||
bbox: rectToBbox(rect),
|
||||
rect,
|
||||
message: `Rotating element's bounding-box center drifts ${Math.round(drift)}px across rotation — it is not spinning about its own center (check transformOrigin/svgOrigin).`,
|
||||
fixHint:
|
||||
"The bounding-box center should stay fixed while the element spins; check its transformOrigin/svgOrigin so rotation pivots about the element's own center rather than a point in a coordinate space it was resized out of.",
|
||||
};
|
||||
}
|
||||
|
||||
function groupRotationSamplesBySelector(samples: RotationSample[]): Map<string, RotationSample[]> {
|
||||
const bySelector = new Map<string, RotationSample[]>();
|
||||
for (const sample of samples) {
|
||||
const group = bySelector.get(sample.selector);
|
||||
if (group) group.push(sample);
|
||||
else bySelector.set(sample.selector, [sample]);
|
||||
}
|
||||
return bySelector;
|
||||
}
|
||||
|
||||
/** Enough samples to establish a spin trajectory (one frame can't). */
|
||||
function hasEnoughRotationSamples(group: RotationSample[]): boolean {
|
||||
return group.length >= ROTATION_MIN_SAMPLES;
|
||||
}
|
||||
|
||||
/** Real spin, not a fixed tilt: the rotation angle actually varies across the grid. */
|
||||
function isActuallySpinning(group: RotationSample[]): boolean {
|
||||
return maxAngleSpread(group.map((s) => s.angle)) > ROTATION_MIN_ANGLE_SPREAD_DEG;
|
||||
}
|
||||
|
||||
/** Rigid bbox size in BOTH dimensions. A scale/entrance animation is not pivot
|
||||
* drift; in particular top-anchored height scaling (fixed width, growing height)
|
||||
* moves the AABB center on its own — the earlier width-only guard let that through
|
||||
* as a false positive. */
|
||||
function isRotationSizeStable(group: RotationSample[]): boolean {
|
||||
const widths = group.map((s) => s.w);
|
||||
const heights = group.map((s) => s.h);
|
||||
const minWidth = Math.min(...widths);
|
||||
const minHeight = Math.min(...heights);
|
||||
if (minWidth <= 0 || minHeight <= 0) return false;
|
||||
return (
|
||||
Math.max(...widths) / minWidth <= ROTATION_MAX_SIZE_RATIO &&
|
||||
Math.max(...heights) / minHeight <= ROTATION_MAX_SIZE_RATIO
|
||||
);
|
||||
}
|
||||
|
||||
/** Skip tiny decorative spinners; only sizable rotating figures matter. */
|
||||
function isSizableRotation(group: RotationSample[]): boolean {
|
||||
return median(group.map((s) => s.w * s.h)) >= ROTATION_MIN_MEDIAN_AREA_PX;
|
||||
}
|
||||
|
||||
/** The size/motion gates a selector group must clear before the (viewport-
|
||||
* dependent) center-drift test. Each is a strict FP guard, deliberately so:
|
||||
* a false positive feeds destructive downstream auto-fixes. */
|
||||
function isRotationDriftCandidate(group: RotationSample[]): boolean {
|
||||
return (
|
||||
hasEnoughRotationSamples(group) &&
|
||||
isActuallySpinning(group) &&
|
||||
isRotationSizeStable(group) &&
|
||||
isSizableRotation(group)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* rotation_pivot_drift: an element that visibly SPINS (its rotation angle varies
|
||||
* across the seek grid) while its bounding-box CENTER travels is pivoting about
|
||||
* the wrong point — the classic symptom of a transformOrigin/svgOrigin authored
|
||||
* as hardcoded pixels against a coordinate space the element was later resized
|
||||
* out of (e.g. spokes set to `250px 250px` inside a 460px-rendered 500-viewBox
|
||||
* SVG). A correctly centered spinner holds its bbox center fixed.
|
||||
*
|
||||
* Cross-sample by necessity — one frame can't distinguish spin-in-place from
|
||||
* pivot drift. FP guards are deliberately strict because a false positive feeds
|
||||
* destructive downstream auto-fixes: requires real rotation, a stable bbox size
|
||||
* in both axes (excludes scale/entrance animations), a sizable element, and
|
||||
* honors `[data-layout-allow-orbit]` opt-outs (applied browser-side).
|
||||
*
|
||||
* Invariant: samples are grouped by `selector`, assumed stable across seeks.
|
||||
* An element without a stable id/class can fall back to a `nth-of-type(N)`
|
||||
* selector whose N shifts as siblings enter/exit — so in that fringe it may be
|
||||
* mis-grouped (a missed detection, or in a rare exit-then-enter aliasing case a
|
||||
* spurious one). Author-crafted rotating figures effectively always carry stable
|
||||
* anchors; a stable-anchor gate on the browser sampler is the structural fix.
|
||||
*/
|
||||
export function detectRotationPivotDrift(
|
||||
samples: RotationSample[],
|
||||
canvas: Canvas,
|
||||
): AnchoredLayoutIssue[] {
|
||||
const findings: AnchoredLayoutIssue[] = [];
|
||||
const viewportFloor = ROTATION_DRIFT_VIEWPORT_FRACTION * Math.min(canvas.width, canvas.height);
|
||||
for (const group of groupRotationSamplesBySelector(samples).values()) {
|
||||
if (!isRotationDriftCandidate(group)) continue;
|
||||
const medianSize = median(group.map((s) => Math.max(s.w, s.h)));
|
||||
const threshold = Math.max(ROTATION_DRIFT_SIZE_FRACTION * medianSize, viewportFloor);
|
||||
const drift = maxCenterDrift(group);
|
||||
if (drift <= threshold) continue;
|
||||
const finding = rotationDriftFinding(group, drift);
|
||||
if (finding) findings.push(finding);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Error-severity findings with real geometry become labeled overview boxes.
|
||||
* Contrast failures are annotated separately by the driver itself, since
|
||||
* they're only known once contrast measurement for this sample completes. */
|
||||
@@ -490,6 +675,10 @@ export async function runAuditGrid(
|
||||
collected.geometrySignatures,
|
||||
motionIssues,
|
||||
);
|
||||
const rotationFindings = detectRotationPivotDrift(
|
||||
collected.rotationSamples,
|
||||
await driver.getCanvas(),
|
||||
);
|
||||
const contrast = buildContrastResults(collected.contrastEntries);
|
||||
return {
|
||||
duration: grid.duration,
|
||||
@@ -497,7 +686,7 @@ export async function runAuditGrid(
|
||||
transitionSamples: grid.transitionSamples,
|
||||
transitionSamplesDropped: grid.transitionSamplesDropped,
|
||||
runtimeFindings: [],
|
||||
layoutIssues: [...collected.layoutIssues, ...sweepFindings],
|
||||
layoutIssues: [...collected.layoutIssues, ...sweepFindings, ...rotationFindings],
|
||||
motionIssues,
|
||||
motionSampleCount: collected.motionFrames.length,
|
||||
contrastSamples: grid.contrastSamples,
|
||||
|
||||
@@ -120,6 +120,19 @@ export interface CheckGeometryCandidate extends CheckAnchor {
|
||||
overflow?: LayoutOverflow;
|
||||
}
|
||||
|
||||
/** One rotatable element's geometry at a single seeked sample, produced by
|
||||
* `__hyperframesRotationSample` (layout-audit.browser.js) and accumulated
|
||||
* across the grid to detect `rotation_pivot_drift`. */
|
||||
export interface RotationSample {
|
||||
time: number;
|
||||
selector: string;
|
||||
cx: number;
|
||||
cy: number;
|
||||
w: number;
|
||||
h: number;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
export type MotionSpecResolution =
|
||||
| { kind: "none" }
|
||||
| { kind: "valid"; path: string; spec: MotionSpec }
|
||||
@@ -137,6 +150,9 @@ export interface CheckAuditDriver {
|
||||
* fingerprint of the current seeked state, for detecting a timeline that
|
||||
* never advances under seek. See layout-audit.browser.js. */
|
||||
collectLayoutGeometry(): Promise<string>;
|
||||
/** rotation_pivot_drift: every rotatable element's bbox center/size/angle at
|
||||
* the current seeked state. Accumulated across the grid — see checkPipeline. */
|
||||
collectRotationSample(time: number): Promise<RotationSample[]>;
|
||||
collectGeometryCandidates(
|
||||
time: number,
|
||||
request: GeometryCandidateRequest,
|
||||
|
||||
@@ -23,6 +23,9 @@ export type LayoutIssueCode =
|
||||
| "escaped_container"
|
||||
| "panel_out_of_canvas"
|
||||
| "connector_detached"
|
||||
// Cross-sample rotation finding — a spinning element whose bbox center drifts
|
||||
// because it pivots about the wrong point (bad transformOrigin/svgOrigin).
|
||||
| "rotation_pivot_drift"
|
||||
// Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample
|
||||
// geometry observation; never persistence-tiered (see `applyPersistenceTier`).
|
||||
| "sweep_static"
|
||||
|
||||
Reference in New Issue
Block a user