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
+27
View File
@@ -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",