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:
Xuanru Li
2026-07-23 01:44:46 -07:00
committed by GitHub
parent 7a294f1956
commit 222aec45ab
7 changed files with 425 additions and 1 deletions
+190 -1
View File
@@ -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,