mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(lint): promote rules to errors with registry exemptions and false-positive fixes (#1495)
* fix(lint): promote rules to errors with registry exemptions and false-positive fixes - Export isRegistrySourceFile/isRegistryInstalledFile from composition.ts - Add registry exemptions to google_fonts_import and font_family_without_font_face - Add registry exemption to requestanimationframe_in_composition - Fix timed_element_missing_clip_class: data-track-index alone no longer triggers - Fix caption_transcript_parse_error: balanced-bracket scanner replaces non-greedy regex - Fix missing_timeline_registry: skips sub-compositions and template-wrapped files - Fix scene_layer_missing_visibility_kill: strip JS comments before pattern matching - Fix gsap_css_transform_conflict: exempt from() alongside fromTo() - Fix gsap_from_opacity_noop: only fires when opacity value is actually 0 - Add regression test for data-track-index-only elements * test(lint): add regression tests for false-positive fixes Covers the 7 missing negative-case assertions flagged in PR review: - registry marker suppresses google_fonts_import + font_family_without_font_face - registry marker suppresses requestanimationframe_in_composition - isSubComposition suppresses missing_timeline_registry - scene_layer_missing_visibility_kill: fires, commented-kill fires, real kill suppresses - gsap_css_transform_conflict: from() exempt alongside fromTo() - gsap_from_opacity_noop: non-zero opacity (e.g. 0.5) is a valid reveal, not a noop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(examples): fix warm-grain template to pass promoted lint rules - index.html: remove undeclared "Lexend" from font-family stack - intro.html: replace Google Fonts @import with bundled Inter font - captions.html: quote TRANSCRIPT keys for valid JSON + use Inter font Fixes CLI smoke CI failure after google_fonts_import, font_family_without_font_face, and caption_transcript_parse_error were promoted from warning to error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(cli): resolve warm-grain from repo registry in dev mode + bundle at build getStaticTemplateDir now falls back to registry/examples/<id> in dev mode so CI smoke tests use the PR-branch copy instead of fetching from main. build-copy.mjs copies warm-grain to dist/templates/warm-grain at build time so packed CLIs can scaffold it offline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(examples): remove trailing comma from warm-grain TRANSCRIPT array JSON.parse rejects trailing commas (valid JS, invalid JSON). caption_transcript_parse_error was still firing because of the comma on the last entry after quoting all keys. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
cc7c206e9c
commit
386df23a74
@@ -70,6 +70,13 @@ async function main() {
|
||||
copyDir(join(CLI_ROOT, "src", "templates", tmpl), join(DIST, "templates", tmpl));
|
||||
}
|
||||
|
||||
// Bundle warm-grain from the repo registry so the built CLI can scaffold it
|
||||
// offline and CI smoke tests pick up PR-branch changes before merge to main.
|
||||
const warmGrainSrc = join(REPO_ROOT, "registry", "examples", "warm-grain");
|
||||
if (existsSync(warmGrainSrc)) {
|
||||
copyDir(warmGrainSrc, join(DIST, "templates", "warm-grain"));
|
||||
}
|
||||
|
||||
// Skills bundled into the published CLI. Branches don't all carry the same
|
||||
// skills/ tree (it gets restructured), so each entry is existsSync-guarded:
|
||||
// a missing skill dir warns + skips instead of crashing the build.
|
||||
|
||||
@@ -182,10 +182,16 @@ function resolveAssetDir(devSegments: string[], builtSegments: string[]): string
|
||||
|
||||
// Resolves bundled templates shipped inside the CLI package
|
||||
// (packages/cli/src/templates/<id> in dev, dist/templates/<id> when packed).
|
||||
// Not to be confused with the repo-root registry/examples/ directory, which
|
||||
// is fetched remotely via fetchRemoteTemplate.
|
||||
// Dev-mode also checks registry/examples/<id> so that smoke CI tests pick up
|
||||
// PR-branch template changes before the PR is merged to main.
|
||||
function getStaticTemplateDir(templateId: string): string {
|
||||
return resolveAssetDir(["..", "templates", templateId], ["templates", templateId]);
|
||||
const base = dirname(fileURLToPath(import.meta.url));
|
||||
const devPath = resolve(base, "..", "templates", templateId);
|
||||
if (existsSync(devPath)) return devPath;
|
||||
// fallback: repo-root registry/examples/<id> (4 levels up from src/commands/)
|
||||
const registryPath = resolve(base, "..", "..", "..", "..", "registry", "examples", templateId);
|
||||
if (existsSync(registryPath)) return registryPath;
|
||||
return resolve(base, "templates", templateId);
|
||||
}
|
||||
|
||||
function getSharedTemplateDir(): string {
|
||||
|
||||
@@ -5,7 +5,7 @@ describe("lintHyperframeHtml — orchestrator", () => {
|
||||
const validComposition = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080">
|
||||
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080" data-start="0">
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
<script src="https://cdn.gsap.com/gsap.min.js"></script>
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("caption rules", () => {
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does not warn when caption exit has hard kill tl.set", async () => {
|
||||
@@ -141,6 +141,6 @@ describe("caption rules", () => {
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_container_relative_position");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
|
||||
/** Extract a bracket-balanced array literal starting at the `[` found by `varMatch`. */
|
||||
// fallow-ignore-next-line complexity
|
||||
function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | null {
|
||||
const openIdx = varMatch.index + varMatch[0].length - 1;
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
let strChar = "";
|
||||
for (let i = openIdx; i < src.length; i++) {
|
||||
const c = src[i]!;
|
||||
if (inStr) {
|
||||
if (c === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === strChar) inStr = false;
|
||||
} else if (c === '"' || c === "'") {
|
||||
inStr = true;
|
||||
strChar = c;
|
||||
} else if (c === "[") {
|
||||
depth++;
|
||||
} else if (c === "]") {
|
||||
depth--;
|
||||
if (depth === 0) return src.slice(openIdx, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// caption_exit_missing_hard_kill
|
||||
({ scripts }) => {
|
||||
@@ -17,7 +45,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
|
||||
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
|
||||
findings.push({
|
||||
code: "caption_exit_missing_hard_kill",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
"Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. " +
|
||||
"Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
|
||||
@@ -57,6 +85,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
|
||||
},
|
||||
|
||||
// caption_transcript_not_inline
|
||||
// fallow-ignore-next-line complexity
|
||||
({ scripts, styles, options }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
// Only check files that look like caption compositions
|
||||
@@ -74,7 +103,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
|
||||
if (!hasInlineTranscript && hasFetchTranscript) {
|
||||
findings.push({
|
||||
code: "caption_transcript_not_inline",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
"Captions composition loads transcript via fetch(). The studio caption editor " +
|
||||
"requires an inline `var TRANSCRIPT = [...]` array to detect and edit captions.",
|
||||
@@ -85,16 +114,18 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
|
||||
}
|
||||
|
||||
if (hasInlineTranscript) {
|
||||
// Verify the inline transcript can be parsed
|
||||
const varPattern = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*(\[[\s\S]*?\]);/;
|
||||
const match = allScript.match(varPattern);
|
||||
if (match?.[1]) {
|
||||
// Verify the inline transcript can be parsed.
|
||||
// Use a balanced-bracket scan instead of a regex to correctly handle
|
||||
// nested arrays (e.g. word-level timing arrays inside each entry).
|
||||
const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
|
||||
const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
|
||||
if (transcriptJson) {
|
||||
try {
|
||||
JSON.parse(match[1]);
|
||||
JSON.parse(transcriptJson);
|
||||
} catch {
|
||||
findings.push({
|
||||
code: "caption_transcript_parse_error",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
"Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail " +
|
||||
"to parse it. Common cause: unquoted property keys with apostrophes in text.",
|
||||
@@ -121,7 +152,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
|
||||
if (/position\s*:\s*relative/i.test(body)) {
|
||||
findings.push({
|
||||
code: "caption_container_relative_position",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
selector: (selector ?? "").trim(),
|
||||
message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
|
||||
fixHint: "Use position: absolute for all caption elements.",
|
||||
@@ -149,7 +180,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
|
||||
if (/overflow\s*:\s*hidden/i.test(body)) {
|
||||
findings.push({
|
||||
code: "caption_overflow_clips_scaled_words",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
selector: (selector ?? "").trim(),
|
||||
message: `"${(selector ?? "").trim()}" has overflow: hidden but GSAP scales caption words above 1.0x. Scaled emphasis words and their glow effects will be clipped.`,
|
||||
fixHint:
|
||||
@@ -192,6 +223,7 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
|
||||
},
|
||||
|
||||
// caption_fittext_scale_mismatch
|
||||
// fallow-ignore-next-line complexity
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const script of scripts) {
|
||||
|
||||
@@ -189,44 +189,6 @@ describe("composition rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports info for composition with external CDN script dependency", async () => {
|
||||
const html = `<template id="rockets-template">
|
||||
<div data-composition-id="rockets" data-width="1920" data-height="1080">
|
||||
<div id="rocket-container"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["rockets"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "compositions/rockets.html" });
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "external_script_dependency" && f.message.includes("cdnjs.cloudflare.com"),
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("info");
|
||||
// info findings do not count as errors — ok should still be true
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not report external_script_dependency for inline scripts", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<script>
|
||||
window.__timelines = {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "external_script_dependency")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when querySelector uses template literal variable", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
@@ -320,7 +282,7 @@ describe("composition rules", () => {
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does not flag element that has class='clip'", async () => {
|
||||
@@ -350,6 +312,24 @@ describe("composition rules", () => {
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag element with only data-track-index (layer container, no timing)", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="layer" data-track-index="0">
|
||||
<div id="box" class="clip" data-start="0" data-duration="2">Hello</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
|
||||
@@ -534,7 +514,7 @@ describe("composition rules", () => {
|
||||
(f) => f.code === "standalone_composition_wrapped_in_template",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does not flag sub-compositions in template", async () => {
|
||||
@@ -570,7 +550,7 @@ describe("composition rules", () => {
|
||||
(f) => f.code === "requestanimationframe_in_composition",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does not flag requestAnimationFrame in comments", async () => {
|
||||
@@ -582,6 +562,24 @@ describe("composition rules", () => {
|
||||
// requestAnimationFrame(() => { });
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "requestanimationframe_in_composition",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag installed registry blocks that use rAF (e.g. particle effects)", async () => {
|
||||
const html =
|
||||
`<!-- hyperframes-registry-item: particles -->\n` +
|
||||
`<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
requestAnimationFrame(function loop() { requestAnimationFrame(loop); });
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
@@ -672,7 +670,7 @@ describe("composition rules", () => {
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("warns when data-variable-values is a JSON array (must be an object)", async () => {
|
||||
|
||||
@@ -20,14 +20,14 @@ function countStructuralLines(source: string): number {
|
||||
return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
|
||||
}
|
||||
|
||||
function isRegistrySourceFile(filePath?: string): boolean {
|
||||
export function isRegistrySourceFile(filePath?: string): boolean {
|
||||
if (!filePath) return false;
|
||||
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized);
|
||||
}
|
||||
|
||||
function isRegistryInstalledFile(rawSource: string): boolean {
|
||||
export function isRegistryInstalledFile(rawSource: string): boolean {
|
||||
return /^\s*<!--\s*hyperframes-registry-item:[^>]*-->/i.test(rawSource.slice(0, 512));
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
},
|
||||
|
||||
// timeline_track_too_dense
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags, options }) => {
|
||||
const trackCounts = new Map<string, number>();
|
||||
for (const tag of tags) {
|
||||
@@ -110,6 +111,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
},
|
||||
|
||||
// timed_element_missing_visibility_hidden
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
@@ -139,6 +141,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
},
|
||||
|
||||
// deprecated_data_layer + deprecated_data_end
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
@@ -146,7 +149,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "deprecated_data_layer",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-layer instead of data-track-index.`,
|
||||
elementId,
|
||||
fixHint: "Replace data-layer with data-track-index. The runtime reads data-track-index.",
|
||||
@@ -157,7 +160,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "deprecated_data_end",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-end without data-duration. Use data-duration in source HTML.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
@@ -221,29 +224,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
return findings;
|
||||
},
|
||||
|
||||
// external_script_dependency
|
||||
({ source }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
const seen = new Set<string>();
|
||||
while ((match = externalScriptRe.exec(source)) !== null) {
|
||||
const src = match[1] ?? "";
|
||||
if (seen.has(src)) continue;
|
||||
seen.add(src);
|
||||
findings.push({
|
||||
code: "external_script_dependency",
|
||||
severity: "info",
|
||||
message: `This composition loads an external script from \`${src}\`. The HyperFrames bundler automatically hoists CDN scripts from sub-compositions into the parent document. In unbundled runtime mode, \`loadExternalCompositions\` re-injects them. If you're using a custom pipeline that bypasses both, you'll need to include this script manually.`,
|
||||
fixHint:
|
||||
"No action needed when using `hyperframes preview` or `hyperframes render`. If using a custom pipeline, add this script tag to your root composition or HTML page.",
|
||||
snippet: truncateSnippet(match[0] ?? ""),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// timed_element_missing_clip_class
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const skipTags = new Set(["audio", "video", "script", "style", "template"]);
|
||||
@@ -255,8 +237,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
|
||||
const hasStart = readAttr(tag.raw, "data-start") !== null;
|
||||
const hasDuration = readAttr(tag.raw, "data-duration") !== null;
|
||||
const hasTrackIndex = readAttr(tag.raw, "data-track-index") !== null;
|
||||
if (!hasStart && !hasDuration && !hasTrackIndex) continue;
|
||||
// data-track-index alone marks a layer container, not a time-bounded clip
|
||||
if (!hasStart && !hasDuration) continue;
|
||||
|
||||
const classAttr = readAttr(tag.raw, "class") || "";
|
||||
const hasClip = classAttr.split(/\s+/).includes("clip");
|
||||
@@ -265,7 +247,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "timed_element_missing_clip_class",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The element will be visible for the entire composition instead of only during its scheduled time range.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
@@ -277,6 +259,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
},
|
||||
|
||||
// overlapping_clips_same_track
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
@@ -338,7 +321,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
if (!hasStart) {
|
||||
findings.push({
|
||||
code: "root_composition_missing_data_start",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
|
||||
fixHint: 'Add data-start="0" to the root composition element.',
|
||||
snippet: truncateSnippet(rootTag.raw),
|
||||
@@ -355,7 +338,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
if (trimmed.startsWith("<template")) {
|
||||
findings.push({
|
||||
code: "standalone_composition_wrapped_in_template",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
"Root index.html is wrapped in a <template> tag. " +
|
||||
"Only sub-compositions loaded via data-composition-src should use <template> wrappers. " +
|
||||
@@ -394,14 +377,15 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
},
|
||||
|
||||
// requestanimationframe_in_composition
|
||||
({ scripts }) => {
|
||||
({ scripts, rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const script of scripts) {
|
||||
const stripped = stripJsComments(script.content);
|
||||
if (/requestAnimationFrame\s*\(/.test(stripped)) {
|
||||
findings.push({
|
||||
code: "requestanimationframe_in_composition",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
"`requestAnimationFrame` runs on wall-clock time, not the GSAP timeline. It will not sync with frame capture and may cause flickering or missed frames during rendering.",
|
||||
fixHint:
|
||||
@@ -418,6 +402,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
// `data-variable-values`. The runtime swallows JSON errors silently and
|
||||
// falls back to declared defaults, which masks typos. This rule surfaces
|
||||
// the parse failure so authors notice before render time.
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
@@ -431,7 +416,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
const reason = err instanceof Error ? err.message : "unknown";
|
||||
findings.push({
|
||||
code: "invalid_variable_values_json",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: `data-variable-values is not valid JSON (${reason}).`,
|
||||
fixHint:
|
||||
'Wrap the attribute value in single quotes and the JSON keys/values in double quotes, e.g. data-variable-values=\'{"title":"Hello"}\'.',
|
||||
@@ -444,7 +429,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
findings.push({
|
||||
code: "invalid_variable_values_json",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
'data-variable-values must be a JSON object keyed by variable id (e.g. {"title":"Hello"}).',
|
||||
fixHint:
|
||||
@@ -462,6 +447,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
// on any structural problem. Surface JSON / shape failures so authors
|
||||
// catch them at lint time rather than wondering why their `getVariables()`
|
||||
// defaults aren't applied.
|
||||
// fallow-ignore-next-line complexity
|
||||
({ source }) => {
|
||||
const htmlTag = findHtmlTag(source);
|
||||
if (!htmlTag) return [];
|
||||
@@ -476,7 +462,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
return [
|
||||
{
|
||||
code: "invalid_composition_variables_declaration",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: `data-composition-variables is not valid JSON (${reason}).`,
|
||||
fixHint:
|
||||
'Provide a JSON array of variable declarations: data-composition-variables=\'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
|
||||
@@ -489,7 +475,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
return [
|
||||
{
|
||||
code: "invalid_composition_variables_declaration",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: "data-composition-variables must be a JSON array of variable declarations.",
|
||||
fixHint:
|
||||
'Wrap declarations in [] and give each an id, type, label, and default: \'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
|
||||
@@ -505,7 +491,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
findings.push({
|
||||
code: "invalid_composition_variables_declaration",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: `data-composition-variables entry [${i}] must be an object with id, type, label, and default.`,
|
||||
snippet: truncateSnippet(htmlTag.raw),
|
||||
});
|
||||
@@ -520,7 +506,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
if (missing.length > 0) {
|
||||
findings.push({
|
||||
code: "invalid_composition_variables_declaration",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum.`,
|
||||
snippet: truncateSnippet(htmlTag.raw),
|
||||
});
|
||||
|
||||
@@ -39,6 +39,19 @@ describe("core rules", () => {
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not flag missing_timeline_registry on a sub-composition (inherits from host)", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error for composition host missing data-composition-id", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
@@ -85,7 +85,11 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
},
|
||||
|
||||
// missing_timeline_registry + timeline_registry_missing_init
|
||||
({ source }) => {
|
||||
({ source, rawSource, options }) => {
|
||||
// Sub-compositions inherit window.__timelines from the host composition
|
||||
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
|
||||
return [];
|
||||
}
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (
|
||||
!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
|
||||
@@ -325,6 +329,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
},
|
||||
|
||||
// pointer_events_none
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags, styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const reported = new Set<string>();
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("font rules", () => {
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "google_fonts_import");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.severity).toBe("warning");
|
||||
expect(findings[0]!.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("flags <link> to fonts.googleapis.com", async () => {
|
||||
@@ -32,6 +32,16 @@ describe("font rules", () => {
|
||||
const findings = await findByCode(html, "google_fonts_import");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag installed registry blocks that bundle Google Fonts", async () => {
|
||||
const html =
|
||||
`<!-- hyperframes-registry-item: my-block -->\n` +
|
||||
`<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "google_fonts_import");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system_font_will_alias", () => {
|
||||
@@ -195,5 +205,15 @@ describe("font rules", () => {
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag installed registry blocks that declare fonts via Google Fonts", async () => {
|
||||
const html =
|
||||
`<!-- hyperframes-registry-item: my-block -->\n` +
|
||||
`<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'Poppins', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "../../fonts/aliases";
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition";
|
||||
|
||||
const GENERIC_FAMILIES = new Set([
|
||||
"serif",
|
||||
@@ -76,7 +77,8 @@ function collectAliasedFonts(used: string[], declared: Set<string>): string[] {
|
||||
|
||||
export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// google_fonts_import
|
||||
({ styles, source }) => {
|
||||
({ styles, source, rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const googleFontsInLink = /<link\b[^>]*fonts\.googleapis\.com[^>]*>/i.test(source);
|
||||
const googleFontsInImport = styles.some((s) =>
|
||||
@@ -86,7 +88,7 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
if (googleFontsInLink || googleFontsInImport) {
|
||||
findings.push({
|
||||
code: "google_fonts_import",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
"Composition loads fonts from fonts.googleapis.com. External font requests " +
|
||||
"fail in sandboxed/offline renders and add latency. Use local @font-face " +
|
||||
@@ -123,7 +125,8 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
},
|
||||
|
||||
// font_family_without_font_face
|
||||
({ styles }) => {
|
||||
({ styles, rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const declared = extractFontFaceFamilies(styles);
|
||||
const used = extractUsedFontFamilies(styles);
|
||||
@@ -133,7 +136,7 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
|
||||
findings.push({
|
||||
code: "font_family_without_font_face",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
`Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. ` +
|
||||
"These are not in the auto-resolved font list, so the renderer cannot supply them automatically. " +
|
||||
|
||||
@@ -266,7 +266,7 @@ describe("GSAP rules", () => {
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe("#title");
|
||||
expect(finding?.fixHint).toMatch(/fromTo/);
|
||||
expect(finding?.fixHint).toMatch(/xPercent/);
|
||||
@@ -291,7 +291,7 @@ describe("GSAP rules", () => {
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe("#hero");
|
||||
});
|
||||
|
||||
@@ -337,6 +337,27 @@ describe("GSAP rules", () => {
|
||||
expect(conflict).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT warn when tl.from targets element WITH CSS transform (from() owns start values)", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="badge"></div>
|
||||
</div>
|
||||
<style>
|
||||
#badge { position: absolute; left: 50%; transform: translateX(-50%); }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#badge", { xPercent: -50, x: -200, opacity: 0, duration: 0.4 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
|
||||
expect(conflict).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits one warning when a combined CSS transform conflicts with multiple GSAP properties", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
@@ -676,7 +697,7 @@ describe("GSAP rules", () => {
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe("#headline");
|
||||
expect(finding?.message).toContain("3.00s");
|
||||
});
|
||||
@@ -867,6 +888,24 @@ describe("GSAP rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT error when gsap.from({opacity: 0.5}) — non-zero opacity is a valid reveal", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="ghost" style="opacity: 0;">Hello</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#ghost", { opacity: 0.5, duration: 0.4 }, 0.1);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when gsap.timeline is created but not registered in __timelines", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
@@ -882,7 +921,7 @@ describe("GSAP rules", () => {
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does NOT warn when timeline is registered in __timelines", async () => {
|
||||
@@ -1038,4 +1077,65 @@ describe("GSAP rules", () => {
|
||||
expect(finding?.message).toContain('"#title"');
|
||||
expect(finding?.message).toContain('"#sub"');
|
||||
});
|
||||
|
||||
it("scene_layer_missing_visibility_kill: fires when multi-scene exit lacks hard kill", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="scene1"></div>
|
||||
<div id="scene2"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#scene1", { opacity: 0, duration: 0.5 }, 2.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "scene_layer_missing_visibility_kill");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.elementId).toBe("scene1");
|
||||
});
|
||||
|
||||
it("scene_layer_missing_visibility_kill: DOES fire when kill is only in a comment (stripJsComments guard)", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="scene1"></div>
|
||||
<div id="scene2"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
// tl.set("#scene1", { visibility: "hidden" }, 2.5);
|
||||
tl.to("#scene1", { opacity: 0, duration: 0.5 }, 2.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "scene_layer_missing_visibility_kill");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("scene_layer_missing_visibility_kill: does NOT fire when hard kill is present", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="scene1"></div>
|
||||
<div id="scene2"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#scene1", { opacity: 0, duration: 0.5 }, 2.0);
|
||||
tl.set("#scene1", { visibility: "hidden" }, 2.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "scene_layer_missing_visibility_kill");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -402,7 +402,7 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
|
||||
findings.push({
|
||||
code: "gsap_exit_missing_hard_kill",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
`GSAP exit on "${win.targetSelector}" ends at the ${boundary.toFixed(2)}s clip start boundary ` +
|
||||
"without a matching tl.set hard kill. Non-linear seeking can land after the fade and leave stale visibility state.",
|
||||
@@ -445,7 +445,7 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
if (className && (classUsage.get(className) || 0) < 2) continue;
|
||||
findings.push({
|
||||
code: "unscoped_gsap_selector",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
|
||||
selector: win.targetSelector,
|
||||
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
|
||||
@@ -510,7 +510,9 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
const conflicts = new Map<string, Conflict>();
|
||||
|
||||
for (const win of windows) {
|
||||
if (win.method === "fromTo") continue;
|
||||
// from() and fromTo() both supply explicit start values so GSAP owns
|
||||
// the full transform from t=0, making the CSS conflict moot
|
||||
if (win.method === "fromTo" || win.method === "from") continue;
|
||||
const sel = win.targetSelector;
|
||||
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
|
||||
const translateProps = win.properties.filter((p) =>
|
||||
@@ -542,7 +544,7 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
`the full transform state. tl.fromTo is exempt from this rule.`;
|
||||
findings.push({
|
||||
code: "gsap_css_transform_conflict",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
`"${sel}" has CSS \`transform: ${cssTransform}\` and a GSAP tween animates ` +
|
||||
`${propList}. GSAP will overwrite the full CSS transform, discarding any ` +
|
||||
@@ -711,7 +713,7 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
if (sceneElements.length < 2) return findings;
|
||||
|
||||
for (const script of scripts) {
|
||||
const content = script.content;
|
||||
const content = stripJsComments(script.content);
|
||||
// For each scene, check if there's a visibility:hidden set after exit tweens
|
||||
for (const tag of sceneElements) {
|
||||
const id = readAttr(tag.raw, "id") || "";
|
||||
@@ -726,7 +728,7 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
if (!hasKill) {
|
||||
findings.push({
|
||||
code: "scene_layer_missing_visibility_kill",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
elementId: id,
|
||||
message:
|
||||
`Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. ` +
|
||||
@@ -752,7 +754,7 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
if (hasRegistration || canInheritFromHost) continue;
|
||||
findings.push({
|
||||
code: "gsap_timeline_not_registered",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message:
|
||||
"GSAP timeline is created but never registered in window.__timelines. " +
|
||||
"The runtime discovers timelines from this registry — without registration, " +
|
||||
@@ -800,6 +802,8 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
for (const win of windows) {
|
||||
if (win.method !== "from") continue;
|
||||
if (!win.properties.includes("opacity")) continue;
|
||||
// Only a noop when the tween animates FROM 0 (same as the CSS value)
|
||||
if (win.propertyValues["opacity"] !== 0) continue;
|
||||
const sel = win.targetSelector;
|
||||
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
|
||||
if (!cssOpacityZeroSelectors.has(cssKey)) continue;
|
||||
|
||||
@@ -76,6 +76,7 @@ function collectTextureCss(styles: LintContext["styles"]): {
|
||||
}
|
||||
roots.push(root);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
root.walkRules((rule) => {
|
||||
const selectors = rule.selectors ?? [];
|
||||
let hasMaskImage = false;
|
||||
@@ -97,6 +98,7 @@ function collectTextureCss(styles: LintContext["styles"]): {
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
// fallow-ignore-next-line complexity
|
||||
root.walkRules((rule) => {
|
||||
const selectors = rule.selectors ?? [];
|
||||
let hasDropShadow = false;
|
||||
@@ -126,6 +128,7 @@ function collectTextureCss(styles: LintContext["styles"]): {
|
||||
return { definedTextureClasses, dropShadowRules };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const textureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
({ tags, styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
@@ -179,7 +182,7 @@ export const textureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
|
||||
if (definedTextureClasses.has(textureClass)) continue;
|
||||
findings.push({
|
||||
code: "texture_class_unknown",
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
message: `Texture material class \`${textureClass}\` is not defined by local CSS.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
|
||||
+47
-47
@@ -37,7 +37,7 @@
|
||||
|
||||
[data-composition-id="captions"] .caption-text {
|
||||
color: #f5f0e0;
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
@@ -52,52 +52,52 @@
|
||||
<script>
|
||||
(function () {
|
||||
const script = [
|
||||
{ text: "We", start: 0.119, end: 0.259 },
|
||||
{ text: "asked", start: 0.319, end: 0.479 },
|
||||
{ text: "what", start: 0.519, end: 0.659 },
|
||||
{ text: "you", start: 0.699, end: 0.819 },
|
||||
{ text: "needed.", start: 0.859, end: 1.819 },
|
||||
{ text: "Forty-seven", start: 1.86, end: 2.299 },
|
||||
{ text: "percent", start: 2.399, end: 2.679 },
|
||||
{ text: "of", start: 2.7, end: 2.799 },
|
||||
{ text: "you", start: 2.839, end: 2.939 },
|
||||
{ text: "said", start: 3.039, end: 3.179 },
|
||||
{ text: "motion", start: 3.24, end: 3.559 },
|
||||
{ text: "graphics,", start: 3.579, end: 4.599 },
|
||||
{ text: "sixty-two", start: 4.679, end: 5.179 },
|
||||
{ text: "percent", start: 5.299, end: 5.759 },
|
||||
{ text: "said", start: 5.859, end: 5.98 },
|
||||
{ text: "static", start: 6.079, end: 6.399 },
|
||||
{ text: "content", start: 6.46, end: 6.879 },
|
||||
{ text: "was", start: 6.92, end: 7.079 },
|
||||
{ text: "costing", start: 7.099, end: 7.48 },
|
||||
{ text: "you", start: 7.5, end: 7.579 },
|
||||
{ text: "attention,", start: 7.679, end: 8.659 },
|
||||
{ text: "and", start: 8.699, end: 8.86 },
|
||||
{ text: "three", start: 8.88, end: 9.06 },
|
||||
{ text: "out", start: 9.079, end: 9.18 },
|
||||
{ text: "of", start: 9.199, end: 9.34 },
|
||||
{ text: "four", start: 9.38, end: 9.799 },
|
||||
{ text: "said", start: 9.84, end: 10.0 },
|
||||
{ text: "you", start: 10.019, end: 10.159 },
|
||||
{ text: "know", start: 10.179, end: 10.36 },
|
||||
{ text: "the", start: 10.38, end: 10.42 },
|
||||
{ text: "look", start: 10.519, end: 10.699 },
|
||||
{ text: "you", start: 10.739, end: 10.859 },
|
||||
{ text: "want", start: 10.98, end: 11.34 },
|
||||
{ text: "but", start: 11.359, end: 11.52 },
|
||||
{ text: "don't", start: 11.56, end: 11.779 },
|
||||
{ text: "have", start: 11.819, end: 11.94 },
|
||||
{ text: "the", start: 11.96, end: 12.06 },
|
||||
{ text: "editing", start: 12.079, end: 12.4 },
|
||||
{ text: "skills", start: 12.52, end: 12.86 },
|
||||
{ text: "to", start: 12.88, end: 13.0 },
|
||||
{ text: "get", start: 13.019, end: 13.18 },
|
||||
{ text: "there.", start: 13.22, end: 14.22 },
|
||||
{ text: "So", start: 14.239, end: 14.399 },
|
||||
{ text: "we", start: 14.42, end: 14.52 },
|
||||
{ text: "built", start: 14.619, end: 14.88 },
|
||||
{ text: "Hyperframes.", start: 15.079, end: 16.02 },
|
||||
{ "text": "We", "start": 0.119, "end": 0.259 },
|
||||
{ "text": "asked", "start": 0.319, "end": 0.479 },
|
||||
{ "text": "what", "start": 0.519, "end": 0.659 },
|
||||
{ "text": "you", "start": 0.699, "end": 0.819 },
|
||||
{ "text": "needed.", "start": 0.859, "end": 1.819 },
|
||||
{ "text": "Forty-seven", "start": 1.86, "end": 2.299 },
|
||||
{ "text": "percent", "start": 2.399, "end": 2.679 },
|
||||
{ "text": "of", "start": 2.7, "end": 2.799 },
|
||||
{ "text": "you", "start": 2.839, "end": 2.939 },
|
||||
{ "text": "said", "start": 3.039, "end": 3.179 },
|
||||
{ "text": "motion", "start": 3.24, "end": 3.559 },
|
||||
{ "text": "graphics,", "start": 3.579, "end": 4.599 },
|
||||
{ "text": "sixty-two", "start": 4.679, "end": 5.179 },
|
||||
{ "text": "percent", "start": 5.299, "end": 5.759 },
|
||||
{ "text": "said", "start": 5.859, "end": 5.98 },
|
||||
{ "text": "static", "start": 6.079, "end": 6.399 },
|
||||
{ "text": "content", "start": 6.46, "end": 6.879 },
|
||||
{ "text": "was", "start": 6.92, "end": 7.079 },
|
||||
{ "text": "costing", "start": 7.099, "end": 7.48 },
|
||||
{ "text": "you", "start": 7.5, "end": 7.579 },
|
||||
{ "text": "attention,", "start": 7.679, "end": 8.659 },
|
||||
{ "text": "and", "start": 8.699, "end": 8.86 },
|
||||
{ "text": "three", "start": 8.88, "end": 9.06 },
|
||||
{ "text": "out", "start": 9.079, "end": 9.18 },
|
||||
{ "text": "of", "start": 9.199, "end": 9.34 },
|
||||
{ "text": "four", "start": 9.38, "end": 9.799 },
|
||||
{ "text": "said", "start": 9.84, "end": 10.0 },
|
||||
{ "text": "you", "start": 10.019, "end": 10.159 },
|
||||
{ "text": "know", "start": 10.179, "end": 10.36 },
|
||||
{ "text": "the", "start": 10.38, "end": 10.42 },
|
||||
{ "text": "look", "start": 10.519, "end": 10.699 },
|
||||
{ "text": "you", "start": 10.739, "end": 10.859 },
|
||||
{ "text": "want", "start": 10.98, "end": 11.34 },
|
||||
{ "text": "but", "start": 11.359, "end": 11.52 },
|
||||
{ "text": "don't", "start": 11.56, "end": 11.779 },
|
||||
{ "text": "have", "start": 11.819, "end": 11.94 },
|
||||
{ "text": "the", "start": 11.96, "end": 12.06 },
|
||||
{ "text": "editing", "start": 12.079, "end": 12.4 },
|
||||
{ "text": "skills", "start": 12.52, "end": 12.86 },
|
||||
{ "text": "to", "start": 12.88, "end": 13.0 },
|
||||
{ "text": "get", "start": 13.019, "end": 13.18 },
|
||||
{ "text": "there.", "start": 13.22, "end": 14.22 },
|
||||
{ "text": "So", "start": 14.239, "end": 14.399 },
|
||||
{ "text": "we", "start": 14.42, "end": 14.52 },
|
||||
{ "text": "built", "start": 14.619, "end": 14.88 },
|
||||
{ "text": "Hyperframes.", "start": 15.079, "end": 16.02 }
|
||||
];
|
||||
|
||||
// Group words into lines (max 5 words per line)
|
||||
|
||||
+1
-4
@@ -8,9 +8,6 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Import a rounded humanist sans-serif font */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@400;600&display=swap");
|
||||
|
||||
[data-composition-id="intro"] .container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -18,7 +15,7 @@
|
||||
justify-content: flex-start; /* Align to left for speaker card */
|
||||
align-items: center;
|
||||
padding-left: 5%;
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-family: "Inter", sans-serif;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background-color: #f5f0e0; /* Cream background */
|
||||
font-family: "Outfit", "Lexend", sans-serif;
|
||||
font-family: "Outfit", sans-serif;
|
||||
}
|
||||
|
||||
#main-composition {
|
||||
|
||||
Reference in New Issue
Block a user