From cd1581b2cf519f8c2998a8bb55527b8954689cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 4 Aug 2026 21:30:40 +0000 Subject: [PATCH] fix(cli): ignore ancestor motion in pivot drift audit --- .../cli/src/commands/layout-audit.browser.js | 10 ++++++ packages/cli/src/utils/checkBrowser.ts | 13 ++++++-- .../checkPipeline.rotationPivotDrift.test.ts | 31 ++++++++++++++++++- packages/cli/src/utils/checkPipeline.ts | 9 +++++- packages/cli/src/utils/checkTypes.ts | 3 ++ 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 8c86391cce..23a96d2c5f 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -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]") || @@ -1572,6 +1581,7 @@ w: round(box.width), h: round(box.height), angle: round(angle), + ancestorTransformSignature: ancestorTransformSignature(element, root), }); } return samples; diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 012708b217..58a848c609 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -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 { diff --git a/packages/cli/src/utils/checkPipeline.rotationPivotDrift.test.ts b/packages/cli/src/utils/checkPipeline.rotationPivotDrift.test.ts index 13524af6a8..65621b7752 100644 --- a/packages/cli/src/utils/checkPipeline.rotationPivotDrift.test.ts +++ b/packages/cli/src/utils/checkPipeline.rotationPivotDrift.test.ts @@ -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 { - 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. */ @@ -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. diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index c984f7e52d..144745256c 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -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) ); } diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 5baa31916f..688f624080 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -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