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
+1
View File
@@ -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;
};
})();