mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +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;
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user