diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 3255841699..09b214aa34 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -149,6 +149,7 @@ function fakeDriver(overrides: Partial = {}): 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[]) => diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 4eb5184d92..78b9dd8da8 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -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; + }; })(); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 427719e239..2da9fc1918 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -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 { }); } +async function collectRotationSample(page: Page, time: number): Promise { + 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", diff --git a/packages/cli/src/utils/checkPipeline.rotationPivotDrift.test.ts b/packages/cli/src/utils/checkPipeline.rotationPivotDrift.test.ts new file mode 100644 index 0000000000..6a22a72cb3 --- /dev/null +++ b/packages/cli/src/utils/checkPipeline.rotationPivotDrift.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 8148654b24..5467414c41 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -48,6 +48,7 @@ import type { ContrastAuditEntry, GeometryCandidateRequest, MotionSpecResolution, + RotationSample, } from "./checkTypes.js"; export type { @@ -189,6 +190,9 @@ interface GridSamples { contrastMs: number; /** One geometry+opacity fingerprint per layout sample (#U10 frozen-sweep guard). */ geometrySignatures: string[]; + /** Every rotatable element's geometry at each layout sample; grouped by + * selector after the run to detect rotation_pivot_drift. */ + rotationSamples: RotationSample[]; } interface GeometrySeen { @@ -360,6 +364,7 @@ async function collectGridSamples( screenshots: [], contrastMs: 0, geometrySignatures: [], + rotationSamples: [], }; for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) { await driver.seek(time); @@ -372,6 +377,7 @@ async function collectGridSamples( collected.layoutIssues.push(...layoutIssues); issuesAtTime.push(...layoutIssues); collected.geometrySignatures.push(await driver.collectLayoutGeometry()); + collected.rotationSamples.push(...(await driver.collectRotationSample(time))); } if (canvas) { const geometryIssues = await collectGeometryAt( @@ -455,6 +461,194 @@ function detectSweepStatic( ]; } +// rotation_pivot_drift thresholds. A spinning element's bbox center should be +// fixed; drift beyond this signals a wrong pivot (transformOrigin/svgOrigin). +const ROTATION_MIN_SAMPLES = 3; +// Degrees of angle spread that count as "actually spinning" (vs a static tilt). +const ROTATION_MIN_ANGLE_SPREAD_DEG = 20; +// Max bbox-width ratio across samples — above this it is scaling/entrancing, +// not spinning in place. A rigid anisotropic shape's axis-aligned bbox +// oscillates under rotation on its own (a plain square already swings 1.41x +// between flat and 45deg), so this must sit above that; 1.6 admits rotating +// squares/mild rectangles while still excluding gross scale/entrance growth +// and thin bars/lines whose bbox swings many-fold (e.g. a rotating reference +// arm). The bbox-CENTER drift below is the real spin-in-place discriminator. +const ROTATION_MAX_SIZE_RATIO = 1.6; +// Skip tiny decorative spinners; only sizable rotating figures matter. +const ROTATION_MIN_MEDIAN_AREA_PX = 2500; +const ROTATION_DRIFT_SIZE_FRACTION = 0.1; +const ROTATION_DRIFT_VIEWPORT_FRACTION = 0.02; +// Absolute drift floor: below this a "drift" is subpixel/AABB noise from small +// elements, not an off-pivot bug. Every real off-pivot case measured in the fuzz +// eval drifted >200px, so this floor drops noise (e.g. a 30px status chip) with +// zero true-positive loss. +const ROTATION_DRIFT_ABS_FLOOR_PX = 80; + +function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const upper = sorted[mid] ?? 0; + return sorted.length % 2 === 0 ? ((sorted[mid - 1] ?? 0) + upper) / 2 : upper; +} + +/** Widest angular gap between any two samples, wrap-aware (0/360 continuity). */ +function maxAngleSpread(angles: number[]): number { + let max = 0; + for (let i = 0; i < angles.length; i++) { + const a = angles[i]; + if (a === undefined) continue; + for (let j = i + 1; j < angles.length; j++) { + const b = angles[j]; + if (b === undefined) continue; + const raw = Math.abs(a - b) % 360; + max = Math.max(max, Math.min(raw, 360 - raw)); + } + } + return max; +} + +/** Largest distance between any two bbox centers across the samples. */ +function maxCenterDrift(samples: RotationSample[]): number { + let max = 0; + for (let i = 0; i < samples.length; i++) { + const first = samples[i]; + if (!first) continue; + for (let j = i + 1; j < samples.length; j++) { + const second = samples[j]; + if (!second) continue; + max = Math.max(max, Math.hypot(first.cx - second.cx, first.cy - second.cy)); + } + } + return max; +} + +function rotationDriftFinding( + samples: RotationSample[], + drift: number, +): AnchoredLayoutIssue | null { + const last = samples[samples.length - 1]; + if (!last) return null; + const rect: LayoutRect = { + left: last.cx - last.w / 2, + top: last.cy - last.h / 2, + right: last.cx + last.w / 2, + bottom: last.cy + last.h / 2, + width: last.w, + height: last.h, + }; + return { + code: "rotation_pivot_drift", + // EF promotes this to error separately; keep it a warning here. + severity: "warning", + time: last.time, + selector: last.selector, + dataAttributes: {}, + sourceFile: "index.html", + bbox: rectToBbox(rect), + rect, + message: `Rotating element's bounding-box center drifts ${Math.round(drift)}px across rotation — it is not spinning about its own center (check transformOrigin/svgOrigin).`, + fixHint: + "The bounding-box center should stay fixed while the element spins; check its transformOrigin/svgOrigin so rotation pivots about the element's own center rather than a point in a coordinate space it was resized out of.", + }; +} + +function groupRotationSamplesBySelector(samples: RotationSample[]): Map { + const bySelector = new Map(); + for (const sample of samples) { + const group = bySelector.get(sample.selector); + if (group) group.push(sample); + else bySelector.set(sample.selector, [sample]); + } + return bySelector; +} + +/** Enough samples to establish a spin trajectory (one frame can't). */ +function hasEnoughRotationSamples(group: RotationSample[]): boolean { + return group.length >= ROTATION_MIN_SAMPLES; +} + +/** Real spin, not a fixed tilt: the rotation angle actually varies across the grid. */ +function isActuallySpinning(group: RotationSample[]): boolean { + return maxAngleSpread(group.map((s) => s.angle)) > ROTATION_MIN_ANGLE_SPREAD_DEG; +} + +/** Rigid bbox size in BOTH dimensions. A scale/entrance animation is not pivot + * drift; in particular top-anchored height scaling (fixed width, growing height) + * moves the AABB center on its own — the earlier width-only guard let that through + * as a false positive. */ +function isRotationSizeStable(group: RotationSample[]): boolean { + const widths = group.map((s) => s.w); + const heights = group.map((s) => s.h); + const minWidth = Math.min(...widths); + const minHeight = Math.min(...heights); + if (minWidth <= 0 || minHeight <= 0) return false; + return ( + Math.max(...widths) / minWidth <= ROTATION_MAX_SIZE_RATIO && + Math.max(...heights) / minHeight <= ROTATION_MAX_SIZE_RATIO + ); +} + +/** Skip tiny decorative spinners; only sizable rotating figures matter. */ +function isSizableRotation(group: RotationSample[]): boolean { + return median(group.map((s) => s.w * s.h)) >= ROTATION_MIN_MEDIAN_AREA_PX; +} + +/** The size/motion gates a selector group must clear before the (viewport- + * dependent) center-drift test. Each is a strict FP guard, deliberately so: + * a false positive feeds destructive downstream auto-fixes. */ +function isRotationDriftCandidate(group: RotationSample[]): boolean { + return ( + hasEnoughRotationSamples(group) && + isActuallySpinning(group) && + isRotationSizeStable(group) && + isSizableRotation(group) + ); +} + +/** + * rotation_pivot_drift: an element that visibly SPINS (its rotation angle varies + * across the seek grid) while its bounding-box CENTER travels is pivoting about + * the wrong point — the classic symptom of a transformOrigin/svgOrigin authored + * as hardcoded pixels against a coordinate space the element was later resized + * out of (e.g. spokes set to `250px 250px` inside a 460px-rendered 500-viewBox + * SVG). A correctly centered spinner holds its bbox center fixed. + * + * Cross-sample by necessity — one frame can't distinguish spin-in-place from + * pivot drift. FP guards are deliberately strict because a false positive feeds + * destructive downstream auto-fixes: requires real rotation, a stable bbox size + * in both axes (excludes scale/entrance animations), a sizable element, and + * honors `[data-layout-allow-orbit]` opt-outs (applied browser-side). + * + * Invariant: samples are grouped by `selector`, assumed stable across seeks. + * An element without a stable id/class can fall back to a `nth-of-type(N)` + * selector whose N shifts as siblings enter/exit — so in that fringe it may be + * mis-grouped (a missed detection, or in a rare exit-then-enter aliasing case a + * spurious one). Author-crafted rotating figures effectively always carry stable + * anchors; a stable-anchor gate on the browser sampler is the structural fix. + */ +export function detectRotationPivotDrift( + samples: RotationSample[], + canvas: Canvas, +): AnchoredLayoutIssue[] { + const findings: AnchoredLayoutIssue[] = []; + const viewportFloor = ROTATION_DRIFT_VIEWPORT_FRACTION * Math.min(canvas.width, canvas.height); + for (const group of groupRotationSamplesBySelector(samples).values()) { + if (!isRotationDriftCandidate(group)) continue; + const medianSize = median(group.map((s) => Math.max(s.w, s.h))); + const threshold = Math.max( + ROTATION_DRIFT_SIZE_FRACTION * medianSize, + viewportFloor, + ROTATION_DRIFT_ABS_FLOOR_PX, + ); + const drift = maxCenterDrift(group); + if (drift <= threshold) continue; + const finding = rotationDriftFinding(group, drift); + if (finding) findings.push(finding); + } + return findings; +} + /** Error-severity findings with real geometry become labeled overview boxes. * Contrast failures are annotated separately by the driver itself, since * they're only known once contrast measurement for this sample completes. */ @@ -490,6 +684,10 @@ export async function runAuditGrid( collected.geometrySignatures, motionIssues, ); + const rotationFindings = detectRotationPivotDrift( + collected.rotationSamples, + await driver.getCanvas(), + ); const contrast = buildContrastResults(collected.contrastEntries); return { duration: grid.duration, @@ -497,7 +695,7 @@ export async function runAuditGrid( transitionSamples: grid.transitionSamples, transitionSamplesDropped: grid.transitionSamplesDropped, runtimeFindings: [], - layoutIssues: [...collected.layoutIssues, ...sweepFindings], + layoutIssues: [...collected.layoutIssues, ...sweepFindings, ...rotationFindings], motionIssues, motionSampleCount: collected.motionFrames.length, contrastSamples: grid.contrastSamples, diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 59da4c0692..d04554d7d2 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -120,6 +120,19 @@ export interface CheckGeometryCandidate extends CheckAnchor { overflow?: LayoutOverflow; } +/** One rotatable element's geometry at a single seeked sample, produced by + * `__hyperframesRotationSample` (layout-audit.browser.js) and accumulated + * across the grid to detect `rotation_pivot_drift`. */ +export interface RotationSample { + time: number; + selector: string; + cx: number; + cy: number; + w: number; + h: number; + angle: number; +} + export type MotionSpecResolution = | { kind: "none" } | { kind: "valid"; path: string; spec: MotionSpec } @@ -137,6 +150,9 @@ export interface CheckAuditDriver { * fingerprint of the current seeked state, for detecting a timeline that * never advances under seek. See layout-audit.browser.js. */ collectLayoutGeometry(): Promise; + /** rotation_pivot_drift: every rotatable element's bbox center/size/angle at + * the current seeked state. Accumulated across the grid — see checkPipeline. */ + collectRotationSample(time: number): Promise; collectGeometryCandidates( time: number, request: GeometryCandidateRequest, diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 493afdc7f1..e72df4f87d 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -23,6 +23,9 @@ export type LayoutIssueCode = | "escaped_container" | "panel_out_of_canvas" | "connector_detached" + // Cross-sample rotation finding — a spinning element whose bbox center drifts + // because it pivots about the wrong point (bad transformOrigin/svgOrigin). + | "rotation_pivot_drift" // Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample // geometry observation; never persistence-tiered (see `applyPersistenceTier`). | "sweep_static"