mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user