Skip to content
Open
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
10 changes: 10 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,15 @@
return (Math.atan2(b, a) * 180) / Math.PI;
}

function ancestorTransformSignature(element, root) {
const transforms = [];
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
transforms.push(getComputedStyle(ancestor).transform || "none");
if (ancestor === root) break;
}
return transforms.join("|");
}

window.__hyperframesRotationSample = function collectRotationSample() {
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
Expand All @@ -1572,6 +1581,7 @@
w: round(box.width),
h: round(box.height),
angle: round(angle),
ancestorTransformSignature: ancestorTransformSignature(element, root),
});
}
return samples;
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,10 +559,19 @@ function parseRotationSample(value: unknown, time: number): RotationSample[] {
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) {
const ancestorTransformSignature = stringValue(value, "ancestorTransformSignature");
if (
!selector ||
cx === null ||
cy === null ||
w === null ||
h === null ||
angle === null ||
ancestorTransformSignature === null
) {
return [];
}
return [{ time, selector, cx, cy, w, h, angle }];
return [{ time, selector, cx, cy, w, h, angle, ancestorTransformSignature }];
}

async function collectOffPivotRotationSample(page: Page, time: number): Promise<OffPivotFrame> {
Expand Down
31 changes: 30 additions & 1 deletion packages/cli/src/utils/checkPipeline.rotationPivotDrift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,17 @@ function rotatedAabb(elemW: number, elemH: number, angleDeg: number): { w: numbe

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

/** Rigid rectangle sample: AABB is derived from unrotated size + angle. */
Expand Down Expand Up @@ -176,6 +186,25 @@ describe("detectRotationPivotDrift", () => {
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
});

it("does not attribute translating ancestor motion to the child's rotation pivot", () => {
const group = [
{
...rigidSample(220, 220, { time: 0, angle: 0, cx: 200, cy: 250 }),
ancestorTransformSignature: "matrix(1, 0, 0, 1, 0, 0)",
},
{
...rigidSample(220, 220, { time: 1, angle: 90, cx: 450, cy: 250 }),
ancestorTransformSignature: "matrix(1, 0, 0, 1, 250, 0)",
},
{
...rigidSample(220, 220, { time: 2, angle: 180, cx: 700, cy: 250 }),
ancestorTransformSignature: "matrix(1, 0, 0, 1, 500, 0)",
},
];

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.
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/utils/checkPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,13 +717,20 @@ function isSizableRotation(group: RotationSample[]): boolean {
return median(group.map((s) => s.w * s.h)) >= ROTATION_MIN_MEDIAN_AREA_PX;
}

/** Viewport-center motion is ambiguous while an ancestor transform changes:
* it may be carrying an otherwise correctly pivoted child across the canvas. */
function hasStableAncestorTransforms(group: RotationSample[]): boolean {
return new Set(group.map((sample) => sample.ancestorTransformSignature)).size === 1;
}

/** Size/motion FP gates before the viewport-dependent center-drift test. */
function isRotationDriftCandidate(group: RotationSample[]): boolean {
return (
hasEnoughRotationSamples(group) &&
isActuallySpinning(group) &&
isRotationSizeStable(group) &&
isSizableRotation(group)
isSizableRotation(group) &&
hasStableAncestorTransforms(group)
);
}

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/utils/checkTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ export interface RotationSample {
w: number;
h: number;
angle: number;
/** Computed-transform chain above this element, used to distinguish a bad
* local pivot from viewport motion introduced by an animated ancestor. */
ancestorTransformSignature: string;
}

/** One elongated rotating SVG figure's material geometry at a single seeked
Expand Down
Loading