Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) =>
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
})();
27 changes: 27 additions & 0 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
ContrastCapture,
GeometryCandidateRequest,
MotionSpecResolution,
RotationSample,
RunAuditGrid,
} from "./checkTypes.js";
import type { ProjectDir } from "./project.js";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
137 changes: 137 additions & 0 deletions packages/cli/src/utils/checkPipeline.rotationPivotDrift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";

import { detectRotationPivotDrift } from "./checkPipeline.js";
import type { RotationSample } from "./checkTypes.js";

const CANVAS = { width: 1000, height: 1000 };

/** One rotation sample; defaults describe a large, size-stable element. */
function sample(overrides: Partial<RotationSample> = {}): RotationSample {
return { time: 0, selector: "#spokes", cx: 250, cy: 250, w: 200, h: 200, angle: 0, ...overrides };
}

/** A group that SHOULD fire: spins (0→90→180), size-stable, sizable, and its
* bbox center travels 100px — the wrong-pivot signature. threshold here is
* max(0.1*200, 0.02*1000, 80) = 80px, so 100px drift clears it. */
function driftingSpinner(): RotationSample[] {
return [
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
sample({ time: 1, angle: 90, cx: 250, cy: 300 }),
sample({ time: 2, angle: 180, cx: 250, cy: 350 }),
];
}

describe("detectRotationPivotDrift", () => {
it("fires on a spinning, size-stable element whose bbox center drifts past threshold", () => {
const findings = detectRotationPivotDrift(driftingSpinner(), CANVAS);
expect(findings).toHaveLength(1);
const [f] = findings;
expect(f?.code).toBe("rotation_pivot_drift");
expect(f?.severity).toBe("warning");
expect(f?.selector).toBe("#spokes");
expect(f?.message).toContain("100px");
expect(f?.fixHint).toContain("transformOrigin");
});

// Stage 1 — sample count.
it("does not fire with fewer than the minimum samples", () => {
const group = driftingSpinner().slice(0, 2);
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});

// Stage 2 — real spin. A translating-but-not-spinning element is not our bug.
it("does not fire when the element barely rotates (fixed tilt, not spinning)", () => {
const group = [
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
sample({ time: 1, angle: 2, cx: 250, cy: 300 }),
sample({ time: 2, angle: 4, cx: 250, cy: 300 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});

// Stage 3 — size stability, WIDTH axis (scale/entrance, not pivot drift).
it("does not fire when width scales across samples", () => {
const group = [
sample({ time: 0, angle: 0, w: 100, cx: 250, cy: 250 }),
sample({ time: 1, angle: 90, w: 200, cx: 250, cy: 280 }),
sample({ time: 2, angle: 180, w: 300, cx: 250, cy: 300 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});

// Stage 3 — size stability, HEIGHT axis. Regression for the width-only guard:
// fixed width, top-anchored height growth (top=100 → cy = 100 + h/2) drifts
// the AABB center 50px on its own. Must NOT be reported as pivot drift.
it("does not fire when height scales (top-anchored) even though the AABB center moves", () => {
const group = [
sample({ time: 0, angle: 0, w: 100, h: 50, cx: 250, cy: 125 }),
sample({ time: 1, angle: 90, w: 100, h: 100, cx: 250, cy: 150 }),
sample({ time: 2, angle: 180, w: 100, h: 150, cx: 250, cy: 175 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});

it("does not fire when a sample has a degenerate zero dimension", () => {
const group = driftingSpinner().map((s, i) => (i === 0 ? { ...s, w: 0 } : s));
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});

// Stage 4 — sizable. Tiny decorative spinners are ignored (area < 2500px²).
it("does not fire on a tiny element below the median-area floor", () => {
const group = driftingSpinner().map((s) => ({ ...s, w: 40, h: 40 }));
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});

// Stage 5 — center drift. A correctly-centered spinner holds its bbox center.
it("does not fire on a spinner whose bbox center stays put", () => {
const group = [
sample({ time: 0, angle: 0 }),
sample({ time: 1, angle: 120 }),
sample({ time: 2, angle: 240 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});

it("uses the viewport floor when the element is small relative to a large canvas", () => {
// medianSize=200 → sizeFloor=20; viewportFloor on a 3000px canvas = 60.
// A 40px drift is below 60 → clean; the same group fired on CANVAS above.
const group = [
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
sample({ time: 1, angle: 90, cx: 250, cy: 270 }),
sample({ time: 2, angle: 180, cx: 250, cy: 290 }),
];
expect(detectRotationPivotDrift(group, { width: 3000, height: 3000 })).toHaveLength(0);
});

it("reports each drifting selector independently and leaves clean ones alone", () => {
const clean = driftingSpinner().map((s) => ({ ...s, selector: "#hub", cx: 500, cy: 500 }));
const findings = detectRotationPivotDrift([...driftingSpinner(), ...clean], CANVAS);
expect(findings.map((f) => f.selector)).toEqual(["#spokes"]);
});

// Wrap-aware angle spread across the ±180° discontinuity. A wobble between
// -175° and 175° is ~10° of travel, not ~350°; naive abs-diff would misread it
// as a fast spin and (with a drifting center) fire falsely.
it("does not treat a ±180° boundary wobble as spinning", () => {
const group = [
sample({ time: 0, angle: -175, cx: 250, cy: 250 }),
sample({ time: 1, angle: 175, cx: 250, cy: 280 }),
sample({ time: 2, angle: -172, cx: 250, cy: 300 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});

it("still detects a real spin that crosses the ±180° boundary", () => {
// 170° → -100° → -10° is ~270° of genuine travel across the seam.
const group = [
sample({ time: 0, angle: 170, cx: 250, cy: 250 }),
sample({ time: 1, angle: -100, cx: 250, cy: 300 }),
sample({ time: 2, angle: -10, cx: 250, cy: 350 }),
];
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(1);
});

it("returns nothing for an empty sample set", () => {
expect(detectRotationPivotDrift([], CANVAS)).toHaveLength(0);
});
});
Loading