feat(cli): declarative motion verification in inspect (#1437) (#1459)

Extend `inspect` to verify motion intent against the same seeked timeline
the renderer uses, catching render-≠-preview bugs that layout sampling can't:
entrance reveals the seek skips, broken stagger order, off-frame drift, and
frozen shots.

A `*.motion.json` sidecar next to the composition opts in (auto-discovered,
no flag, no authoring-framework changes); without one, inspect is unchanged.
inspect seeks a dense grid over the asserted selectors, builds an
element × time matrix of {rect, opacity, visible} plus per-scope liveness
signatures, and evaluates four assertions in Node:

  appearsBy    -> motion_appears_late
  before       -> motion_out_of_order
  staysInFrame -> motion_off_frame
  keepsMoving  -> motion_frozen

A selector matching nothing is reported as motion_selector_missing rather
than silently passing. Findings reuse the LayoutIssue shape and flow through
the existing dedupe/collapse/limit/format pipeline and JSON envelope; they
are errors by default, so a failed assertion fails the run.

The motion pass runs in the same Chrome session as the layout audit (no extra
launch) and only when a sidecar is present.
This commit is contained in:
Miguel Ángel
2026-06-15 16:29:11 -04:00
committed by GitHub
parent 1ab7dcfe47
commit 9175eced45
14 changed files with 1270 additions and 30 deletions
@@ -0,0 +1,121 @@
// In-page motion sampler for `hyperframes inspect` motion verification (#1437).
// Runs inside the seeked, paused page (via page.evaluate). For each asserted
// selector it returns this frame's { rect, opacity, visible }; for each liveness
// scope it returns a bucketed signature of all visible elements, so the Node-side
// evaluator can detect frozen windows by comparing signatures across frames.
(function () {
const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
function round(value) {
return Math.round(value * 100) / 100;
}
function toRect(rect) {
return {
left: round(rect.left),
top: round(rect.top),
right: round(rect.right),
bottom: round(rect.bottom),
width: round(rect.width),
height: round(rect.height),
};
}
function opacityChain(element) {
let opacity = 1;
for (let current = element; current; current = current.parentElement) {
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
if (Number.isFinite(parsed)) opacity *= parsed;
}
return opacity;
}
// Mirrors layout-audit.browser.js isVisibleElement.
// fallow-ignore-next-line complexity
function isVisibleElement(element) {
if (IGNORE_TAGS.has(element.tagName)) return false;
const style = getComputedStyle(element);
if (
style.display === "none" ||
style.visibility === "hidden" ||
style.visibility === "collapse"
) {
return false;
}
if (opacityChain(element) < 0.2) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0.5 && rect.height > 0.5;
}
function sampleElement(element) {
const rect = element.getBoundingClientRect();
return {
rect: toRect(rect),
opacity: round(opacityChain(element)),
visible: isVisibleElement(element),
};
}
function compositionRoot() {
return document.querySelector("[data-composition-id]") || document.body;
}
// ponytail: bucket position to 2px and opacity to 0.08 so the RFC's "moves ≥2px /
// opacity ≥0.08" thresholds fall out of bucketing. Boundary-straddling moves are
// approximate — good enough for liveness; tighten only if false negatives show up.
function elementSignature(element) {
const rect = element.getBoundingClientRect();
const bx = Math.round(rect.left / 2);
const by = Math.round(rect.top / 2);
const bw = Math.round(rect.width / 2);
const bh = Math.round(rect.height / 2);
const bo = Math.round(opacityChain(element) / 0.08);
return bx + "," + by + "," + bw + "," + bh + "," + bo;
}
function livenessSignature(root) {
if (!root) return "";
const parts = [];
// ponytail: O(DOM) × MOTION_MAX_SAMPLES (300) — fine for typical compositions;
// narrow selector (e.g. "[id],[class]") if heavy-DOM compositions slow this down.
const all = root.querySelectorAll("*");
for (const element of all) {
if (!isVisibleElement(element)) continue;
parts.push(elementSignature(element));
}
return parts.join("|");
}
function safeQuery(selector) {
try {
return document.querySelector(selector);
} catch {
return null;
}
}
function sampleSelectors(selectors) {
const data = {};
for (const selector of selectors) {
// Multi-match selectors are rejected before this point by findAmbiguousSelectors
// in layout.ts; querySelector is safe here.
const element = safeQuery(selector);
data[selector] = element ? sampleElement(element) : null;
}
return data;
}
function sampleLiveness(scopes) {
const liveness = {};
for (const scope of scopes) {
const root = scope === "*" ? compositionRoot() : safeQuery(scope);
liveness[scope] = livenessSignature(root);
}
return liveness;
}
window.__hyperframesMotionSample = function motionSample(options) {
const { selectors = [], livenessScopes = [] } = options || {};
return { data: sampleSelectors(selectors), liveness: sampleLiveness(livenessScopes) };
};
})();