Files
hyperframes/packages/cli/src/commands/motion-sample.browser.js
T
Miguel Ángel 9175eced45 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.
2026-06-15 16:29:11 -04:00

122 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) };
};
})();