diff --git a/package.json b/package.json
index 79e26c1a5..37140e5ae 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,8 @@
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
"package:codex-plugin": "node scripts/package-codex-plugin.mjs",
"upload:docs-images": "bash scripts/upload-docs-images.sh",
- "prepare": "test -d .git && lefthook install || true"
+ "prepare": "test -d .git && lefthook install || true",
+ "lint:registry-items": "node scripts/lint-registry-items.mjs"
},
"devDependencies": {
"@commitlint/cli": "^20.5.0",
diff --git a/registry/blocks/mk-background/mk-background.html b/registry/blocks/mk-background/mk-background.html
index 82e7759ad..66eac9304 100644
--- a/registry/blocks/mk-background/mk-background.html
+++ b/registry/blocks/mk-background/mk-background.html
@@ -189,13 +189,16 @@
);
}
- /* bar-mask demo: full-bleed -> rounded card over the stage -> full-bleed */
+ /* bar-mask demo: full-bleed -> rounded card over the stage -> full-bleed.
+ Offset via x/y transforms, not top/left: layout properties snap to
+ integer device pixels and stutter under seek-by-frame capture. The
+ card's CSS base is top:0/left:0, so the transform values match 1:1. */
if (CONFIG.bar.demo) {
tl.to(
card,
{
- top: CONFIG.bar.y,
- left: CONFIG.bar.x,
+ y: CONFIG.bar.y,
+ x: CONFIG.bar.x,
width: CONFIG.bar.width,
height: CONFIG.bar.height,
borderRadius: CONFIG.bar.roundness,
@@ -207,8 +210,8 @@
tl.to(
card,
{
- top: 0,
- left: 0,
+ y: 0,
+ x: 0,
width: W,
height: H,
borderRadius: 0,
diff --git a/registry/blocks/mk-clone-wall-transition/mk-clone-wall-transition.html b/registry/blocks/mk-clone-wall-transition/mk-clone-wall-transition.html
index 507b62bdf..784aade22 100644
--- a/registry/blocks/mk-clone-wall-transition/mk-clone-wall-transition.html
+++ b/registry/blocks/mk-clone-wall-transition/mk-clone-wall-transition.html
@@ -182,13 +182,16 @@
/* The wall sits in place from t=0, fully hidden beneath the
full-bleed card — the card's shrink IS the wall's reveal. */
- /* 0.2–0.95 outgoing frame shrinks to a rounded card on the wall */
+ /* 0.2–0.95 outgoing frame shrinks to a rounded card on the wall.
+ x/y rather than left/top — layout properties snap to integer device
+ pixels under seek-by-frame capture. Base is top:0/left:0, so the
+ values carry over unchanged, and the later scale composes cleanly. */
if (CONFIG.card.enabled) {
tl.to(
card,
{
- top: 330,
- left: 640,
+ y: 330,
+ x: 640,
width: 640,
height: 420,
borderRadius: 40,
diff --git a/scripts/lint-registry-items.mjs b/scripts/lint-registry-items.mjs
new file mode 100644
index 000000000..0aceab66f
--- /dev/null
+++ b/scripts/lint-registry-items.mjs
@@ -0,0 +1,121 @@
+#!/usr/bin/env node
+/**
+ * Lint registry blocks/components the way a user actually receives them.
+ *
+ * `hyperframes lint
` needs an `index.html`, but registry items ship as
+ * `.html`, so pointing the linter at an item directory fails with "No
+ * composition found" — which means registry items were never linted at all.
+ * Two `gsap_non_transform_motion` errors reached main that way.
+ *
+ * This mounts each item into a throwaway project (exactly where `hyperframes
+ * add` would put it) and lints that, reporting only findings for the item's
+ * own file so the host scaffold's noise is ignored.
+ *
+ * Usage:
+ * node scripts/lint-registry-items.mjs # every item
+ * node scripts/lint-registry-items.mjs mk-background … # named items
+ */
+import {
+ readdirSync,
+ existsSync,
+ mkdtempSync,
+ mkdirSync,
+ copyFileSync,
+ writeFileSync,
+ rmSync,
+} from "node:fs";
+import { join, resolve, dirname } from "node:path";
+import { tmpdir } from "node:os";
+import { fileURLToPath } from "node:url";
+import { spawnSync } from "node:child_process";
+
+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const cli = join(repoRoot, "packages/cli/src/cli.ts");
+
+// A deliberately boring host: one clip, one registered timeline, so the only
+// findings that can appear are the item's own.
+const HOST = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+/**
+ * Rules that assume a standalone composition. Many components ship as
+ * paste-able fragments with no root element at all, so these fire on ~47
+ * long-standing items and would drown the signal. The host provides the root;
+ * the item is not supposed to.
+ */
+const IGNORED = [
+ "root_missing_composition_id",
+ "root_missing_dimensions",
+ "multiple_root_compositions",
+];
+
+function discover() {
+ const out = [];
+ for (const kind of ["blocks", "components"]) {
+ const dir = join(repoRoot, "registry", kind);
+ if (!existsSync(dir)) continue;
+ for (const name of readdirSync(dir)) {
+ const html = join(dir, name, `${name}.html`);
+ if (existsSync(html)) out.push({ name, kind, html });
+ }
+ }
+ return out;
+}
+
+const only = process.argv.slice(2);
+const items = discover().filter((i) => only.length === 0 || only.includes(i.name));
+if (items.length === 0) {
+ console.error(
+ only.length ? `No registry item matches: ${only.join(", ")}` : "No registry items found.",
+ );
+ process.exit(1);
+}
+
+let failed = 0;
+for (const item of items) {
+ const proj = mkdtempSync(join(tmpdir(), `hf-lint-${item.name}-`));
+ try {
+ mkdirSync(join(proj, "compositions"), { recursive: true });
+ writeFileSync(join(proj, "index.html"), HOST);
+ copyFileSync(item.html, join(proj, "compositions", `${item.name}.html`));
+
+ const res = spawnSync("bun", [cli, "lint", proj], { encoding: "utf-8" });
+ const lines = `${res.stdout ?? ""}${res.stderr ?? ""}`.split("\n");
+ // Only the item's own file — the host scaffold is not under review.
+ const hits = lines.filter(
+ (l) =>
+ l.includes("✗") && l.includes(`${item.name}.html`) && !IGNORED.some((r) => l.includes(r)),
+ );
+ if (hits.length) {
+ failed++;
+ console.error(`\n✗ ${item.kind}/${item.name}`);
+ for (const h of hits) console.error(` ${h.trim()}`);
+ }
+ } finally {
+ rmSync(proj, { recursive: true, force: true });
+ }
+}
+
+if (failed) {
+ console.error(`\n${failed} registry item(s) have lint errors.`);
+ process.exit(1);
+}
+console.log(`Registry item lint passed — ${items.length} item(s), 0 errors.`);