From ce7d75dbaa29e3920216f6e848e68b4b6257602e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 3 Aug 2026 10:14:37 +0200 Subject: [PATCH] fix(registry): animate mk card offsets with transforms, not top/left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mk-background and mk-clone-wall-transition tween the card's `top`/`left`. Layout properties snap to integer device pixels, so the move stutters under the seek-by-frame capture engine (lint: gsap_non_transform_motion). Both cards sit at top:0/left:0 in CSS, so the values carry straight over to x/y, and in clone-wall the later scale composes cleanly with the translate. Re-rendered both and diffed frames against the previous output — identical, as intended: this changes how the motion is computed, not how it looks. Adds scripts/lint-registry-items.mjs (bun run lint:registry-items), which mounts each item into a throwaway project and lints it. Registry items ship as `.html`, so `hyperframes lint ` fails with "No composition found" and these items had never actually been linted — which is how both errors reached main. Verified the script reproduces the original failure on the pre-fix source. Left as a local command rather than a CI gate for now; wiring it up needs two prior fixes, noted in the PR. --- package.json | 3 +- .../blocks/mk-background/mk-background.html | 13 +- .../mk-clone-wall-transition.html | 9 +- scripts/lint-registry-items.mjs | 121 ++++++++++++++++++ 4 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 scripts/lint-registry-items.mjs 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.`);