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
17 changes: 17 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,23 @@
// makes non-trivial.
"packages/studio-server/src/helpers/screenshotClip.ts",
"packages/studio/vite.browser.ts",
// off_pivot_rotation Kåsa circle fit (feat/needle-pivot-offset-check):
// fitCirclePoints in layout-audit.browser.js and fitCircle in
// checkPipeline.ts are the same least-squares circle fit, but the browser
// copy is injected as a raw string via page.addScriptTag and cannot import
// the Node-side module across puppeteer's serialization boundary. The two
// copies carry matching "KEEP IN SYNC" headers; the duplication is
// intentional and per-language, so it's exempted here rather than faked
// away with cosmetic divergence.
"packages/cli/src/commands/layout-audit.browser.js",
"packages/cli/src/utils/checkPipeline.ts",
// check.test.ts: the fakeDriver-based command tests share a pre-existing
// arrange/act/assert scaffold (runScenario + vi.fn runPipeline + spy +
// createCheckCommand). Adding the required collectOffPivotRotationSample
// stub to the CheckAuditDriver fake shifts line numbers and re-flags that
// inherited clone; consistent with the norm above of leaving parallel
// command-test cases unabstracted.
"packages/cli/src/commands/check.test.ts",
],
},
"health": {
Expand Down
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 @@ -150,6 +150,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
collectRotationSample: vi.fn(async (_time: number) => []),
collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })),
collectGeometryCandidates: vi.fn(async () => []),
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
Expand Down
193 changes: 193 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1531,4 +1531,197 @@
}
return samples;
};

// Needle-pivot sampling (off_pivot_rotation). A gauge/clock/radar pointer
// whose center-of-rotation sits far from the dial hub. bbox-intrinsic measures
// can't tell a correct sweep from a broken one (a base-pivoted needle's bbox
// center orbits either way), so this records two MATERIAL points on each
// elongated rotating SVG figure — mapped through getScreenCTM so the actual
// rendered transform is honored regardless of svgOrigin/transform-origin — and
// the dial's static hub (the point shared by the most non-rotating circles).
// The pipeline fits a rotation to the material-point trajectories to recover
// the real center-of-rotation and flags it when it drifts off that hub.
function ctmRotationDeg(ctm) {
if (!ctm) return null;
return (Math.atan2(ctm.b, ctm.a) * 180) / Math.PI;
}

function ctmScale(ctm) {
return Math.hypot(ctm.a, ctm.b);
}

function mapPoint(svg, ctm, x, y) {
const point = svg.createSVGPoint();
point.x = x;
point.y = y;
const mapped = point.matrixTransform(ctm);
return { x: mapped.x, y: mapped.y };
}

// Walks up to (and including) the composition root, NOT just the owner <svg>:
// an element spun by a div ancestor above its svg must not be mistaken for a
// static hub anchor (else a lone rotating arc becomes its own dial center).
function hasRotatedAncestor(element, root) {
let node = element;
while (node) {
const angle = rotationAngleDeg(getComputedStyle(node).transform);
if (angle !== null && Math.abs(angle) > 1) return true;
if (node === root) break;
node = node.parentElement;
}
return false;
}

// KEEP IN SYNC with `fitCircle` in packages/cli/src/utils/checkPipeline.ts —
// this browser copy resolves arc-drawn dial hubs and is injected as a raw
// string (no import across the puppeteer boundary), so the Kåsa math is
// intentionally duplicated per-language. Any change must land in both copies.
function fitCirclePoints(points) {
const count = points.length;
if (count < 3) return null;
const meanX = points.reduce((sum, p) => sum + p.x, 0) / count;
const meanY = points.reduce((sum, p) => sum + p.y, 0) / count;
let suu = 0,
svv = 0,
suv = 0,
suuu = 0,
svvv = 0,
suvv = 0,
svuu = 0;
for (const point of points) {
const u = point.x - meanX;
const v = point.y - meanY;
suu += u * u;
svv += v * v;
suv += u * v;
suuu += u * u * u;
svvv += v * v * v;
suvv += u * v * v;
svuu += v * u * u;
}
const det = suu * svv - suv * suv;
if (Math.abs(det) < 1e-6) return null;
const uc = (((suuu + suvv) / 2) * svv - ((svvv + svuu) / 2) * suv) / det;
const vc = (((svvv + svuu) / 2) * suu - ((suuu + suvv) / 2) * suv) / det;
const cx = uc + meanX;
const cy = vc + meanY;
const radius = Math.sqrt(uc * uc + vc * vc + (suu + svv) / count);
let squaredError = 0;
for (const point of points) {
const delta = Math.hypot(point.x - cx, point.y - cy) - radius;
squaredError += delta * delta;
}
return { cx, cy, radius, residual: Math.sqrt(squaredError / count) };
}

// Fallback for dials drawn as arc <path> rather than <circle> rings: sample
// the largest static, near-circular path and recover its arc center.
function arcHubForSvg(svg, root) {
let best = null;
for (const path of Array.from(svg.querySelectorAll("path"))) {
if (hasRotatedAncestor(path, root)) continue;
if (typeof path.getTotalLength !== "function") continue;
const total = path.getTotalLength();
if (total < 200) continue;
const ctm = path.getScreenCTM();
if (!ctm) continue;
const points = [];
for (let i = 0; i <= 16; i++) {
const local = path.getPointAtLength((total * i) / 16);
points.push(mapPoint(svg, ctm, local.x, local.y));
}
const fit = fitCirclePoints(points);
if (!fit || fit.radius < 40) continue;
if (fit.residual > 0.05 * fit.radius) continue;
if (!best || fit.radius > best.radius) best = fit;
}
return best ? { hx: best.cx, hy: best.cy, hr: best.radius, count: 2 } : null;
}

function dialHubForSvg(svg, root) {
const centers = [];
for (const circle of Array.from(svg.querySelectorAll("circle"))) {
if (hasRotatedAncestor(circle, root)) continue;
const ctm = circle.getScreenCTM();
if (!ctm) continue;
const cx = Number.parseFloat(circle.getAttribute("cx") || "0");
const cy = Number.parseFloat(circle.getAttribute("cy") || "0");
const center = mapPoint(svg, ctm, cx, cy);
const radius = Number.parseFloat(circle.getAttribute("r") || "0") * ctmScale(ctm);
centers.push({ x: center.x, y: center.y, radius });
}
let best = null;
for (const anchor of centers) {
const cluster = centers.filter(
(other) => Math.hypot(other.x - anchor.x, other.y - anchor.y) <= 8,
);
if (!best || cluster.length > best.cluster.length) best = { anchor, cluster };
}
if (best && best.cluster.length >= 2) {
const count = best.cluster.length;
const hx = best.cluster.reduce((sum, item) => sum + item.x, 0) / count;
const hy = best.cluster.reduce((sum, item) => sum + item.y, 0) / count;
const hr = best.cluster.reduce((max, item) => Math.max(max, item.radius), 0);
return { hx, hy, hr, count };
}
return arcHubForSvg(svg, root);
}

window.__hyperframesOffPivotRotationSample = function collectOffPivotRotationSample() {
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
document.body;
const samples = [];
const hubCache = new Map();
const CANDIDATE_CAP = 60;
for (const element of Array.from(
root.querySelectorAll("path, polygon, line, rect, polyline, g"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 line in the candidate selector is dead — filtered out 100% by the short-side gate.

for (const element of Array.from(root.querySelectorAll("path, polygon, line, rect, polyline, g"))) {

<line> elements have zero-thickness bboxes → the short == 0 filter downstream at :1687 excludes them 100% of the time. Wasted work only, no correctness impact — but removing line from the selector makes the intent clear and saves the DOM walk.

Review by Rames D Jusso

)) {
if (samples.length >= CANDIDATE_CAP) break;
const svg = element.ownerSVGElement;
if (!svg || typeof element.getBBox !== "function") continue;
if (element.closest("[data-layout-allow-orbit]")) continue;
if (!isVisibleElement(element, 0.05)) continue;
const ctm = element.getScreenCTM();
const angle = ctmRotationDeg(ctm);
if (ctm === null || angle === null) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Root discovery here is inconsistent with the sibling samplers — could pick the wrong composition-root when nested comps are on-page.

window.__hyperframesOffPivotRotationSample = function collectOffPivotRotationSample() {
  const root = document.querySelector("[data-composition-id]") || document.body;

Compare __hyperframesRotationSample (pre-existing, :1520) and __hyperframesConnectorSample (added in stacked PR #2745, :1821-1823) — both use the 3-tier chain:

document.querySelector("[data-composition-id][data-width][data-height]") ||
  document.querySelector("[data-composition-id]") ||
  document.body;

This sampler skips the primary selector. Same pattern is used consistently elsewhere in this file (:1361, :1394, :1436, :1480).

Failure scenario: a page with multiple [data-composition-id] elements (e.g. studio preview shell wrapping a rendered comp, or a docs page with a stack of thumbnails). This sampler selects the OUTER [data-composition-id] while every other sampler on the same page selects the INNER dimensioned one → off_pivot fires against the shell's coordinate system while all other checks run against the actual comp; hub coordinates become nonsensical.

Invisible in the fuzz corpus (single comp per page); real in studio preview surfaces.

Fix: change the fallback chain to match the sibling samplers, or hoist to a shared hyperframesCompositionRoot() helper at the top of the browser bundle and have all four samplers call it.

Review by Rames D Jusso

let bbox;
try {
bbox = element.getBBox();
} catch {
continue;
}
const long = Math.max(bbox.width, bbox.height);
const short = Math.min(bbox.width, bbox.height);
if (short <= 0 || long / short < 3 || long < 40) continue;
const vertical = bbox.height >= bbox.width;
const midMajor = vertical ? bbox.x + bbox.width / 2 : bbox.y + bbox.height / 2;
const a = vertical
? mapPoint(svg, ctm, midMajor, bbox.y)
: mapPoint(svg, ctm, bbox.x, midMajor);
const b = vertical
? mapPoint(svg, ctm, midMajor, bbox.y + bbox.height)
: mapPoint(svg, ctm, bbox.x + bbox.width, midMajor);
let hub = hubCache.get(svg);
if (hub === undefined) {
hub = dialHubForSvg(svg, root);
hubCache.set(svg, hub);
}
samples.push({
selector: selectorFor(element),
ax: round(a.x),
ay: round(a.y),
bx: round(b.x),
by: round(b.y),
len: round(Math.hypot(b.x - a.x, b.y - a.y)),
angle: round(angle),
hx: hub ? round(hub.hx) : null,
hy: hub ? round(hub.hy) : null,
hr: hub ? round(hub.hr) : null,
hubCount: hub ? hub.count : 0,
});
}
return samples;
};
})();
64 changes: 60 additions & 4 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import type {
ContrastCapture,
GeometryCandidateRequest,
MotionSpecResolution,
OffPivotFrame,
OffPivotRotationSample,
RotationSample,
RunAuditGrid,
} from "./checkTypes.js";
Expand Down Expand Up @@ -349,6 +351,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),
collectOffPivotRotationSample: (time) => collectOffPivotRotationSample(page, time),
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
collectMotionFrame: (time, selectors, scopes) =>
collectMotionFrame(page, time, selectors, scopes),
Expand Down Expand Up @@ -470,13 +473,20 @@ 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");
/** Invoke a `window.__hyperframes*` sampler injected by layout-audit.browser.js
* and return its array result (or [] when absent / non-array). Shared by the
* per-frame sample collectors so the page.evaluate boilerplate lives once. */
async function evaluateSampler(page: Page, globalName: string): Promise<unknown[]> {
return page.evaluate((name) => {
const sample = Reflect.get(window, name);
if (typeof sample !== "function") return [];
const result = Reflect.apply(sample, window, []);
return Array.isArray(result) ? result : [];
});
}, globalName);
}

async function collectRotationSample(page: Page, time: number): Promise<RotationSample[]> {
const raw = await evaluateSampler(page, "__hyperframesRotationSample");
return raw.flatMap((value) => parseRotationSample(value, time));
}

Expand All @@ -494,6 +504,51 @@ function parseRotationSample(value: unknown, time: number): RotationSample[] {
return [{ time, selector, cx, cy, w, h, angle }];
}

async function collectOffPivotRotationSample(page: Page, time: number): Promise<OffPivotFrame> {
const raw = await evaluateSampler(page, "__hyperframesOffPivotRotationSample");
return { time, samples: raw.flatMap(parseOffPivotRotationSample) };
}

/** Read every named key as a finite number; null if ANY is missing/non-finite.
* The mapped return type keeps each field a plain `number` (not `number |
* undefined`) so callers read `nums.ax` without re-narrowing. */
function requiredNumbers<K extends string>(
value: Record<string, unknown>,
keys: readonly K[],
): { [P in K]: number } | null {
const out = {} as { [P in K]: number };
for (const key of keys) {
const num = numberValue(value, key);
if (num === null) return null;
out[key] = num;
}
return out;
}

const OFF_PIVOT_REQUIRED_NUMBERS = ["ax", "ay", "bx", "by", "len", "angle", "hubCount"] as const;

function parseOffPivotRotationSample(value: unknown): OffPivotRotationSample[] {
if (!isRecord(value)) return [];
const selector = stringValue(value, "selector");
const nums = requiredNumbers(value, OFF_PIVOT_REQUIRED_NUMBERS);
if (!selector || !nums) return [];
return [
{
selector,
ax: nums.ax,
ay: nums.ay,
bx: nums.bx,
by: nums.by,
len: nums.len,
angle: nums.angle,
hx: numberValue(value, "hx"),
hy: numberValue(value, "hy"),
hr: numberValue(value, "hr"),
hubCount: nums.hubCount,
},
];
}

async function collectGeometryCandidates(
page: Page,
time: number,
Expand Down Expand Up @@ -1078,6 +1133,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
"panel_out_of_canvas",
"connector_detached",
"rotation_pivot_drift",
"off_pivot_rotation",
"motion_appears_late",
"motion_out_of_order",
"motion_off_frame",
Expand Down
Loading
Loading