feat(lint): warn when a sub-composition slot blanks before the host (#1542)

A sub-composition mount whose data-duration ends before the host
composition's window leaves its slot blank for the remainder. The
runtime behavior is correct (data-duration is the slot's visible window
and takes precedence), but a full-bleed sub-composition shorter than the
composition is almost always an authoring mistake that fails silently
(issue #1540).

Add the subcomposition_blanks_before_host rule, scoped narrowly to the
high-signal shape — a sole/dominant external mount starting at ~0 whose
window ends before the host's — so it stays silent on intentional short
clips. Document the slot-window semantics in the sub-compositions
reference, distinguishing the hold-through-slot case (#911/#917) from
the blank-when-shorter-than-host case.
This commit is contained in:
Miguel Ángel
2026-06-17 17:56:43 -04:00
committed by GitHub
parent 015529e663
commit 13af5540c1
3 changed files with 180 additions and 0 deletions
@@ -820,4 +820,115 @@ describe("composition rules", () => {
expect(finding).toBeUndefined();
});
});
describe("subcomposition_blanks_before_host", () => {
const find = (findings: Array<{ code: string }>) =>
findings.find((f) => f.code === "subcomposition_blanks_before_host");
it("fires on the issue #1540 shape (child shorter than host)", async () => {
const html = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="331.224" data-width="1920" data-height="1080">
<div id="decision-tree-comp" data-composition-id="decision-tree" data-composition-src="compositions/decision_tree.html" data-start="0" data-duration="15"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.message).toContain("blank");
expect(finding?.message).toContain("331.224");
});
it("fires when the mount starts within the start tolerance", async () => {
const html = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0.3" data-duration="15"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
expect(find(result.findings)).toBeDefined();
});
it("fires at exactly the start tolerance boundary (start=0.5)", async () => {
const html = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0.5" data-duration="15"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
expect(find(result.findings)).toBeDefined();
});
it("stays silent on an intentional short intro followed by another clip", async () => {
const html = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
<div data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0" data-duration="15"></div>
<div data-composition-id="body" data-composition-src="compositions/body.html" data-start="15" data-duration="45"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
expect(find(result.findings)).toBeUndefined();
});
it("stays silent when the child matches the host window", async () => {
const html = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="15">
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="15"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
expect(find(result.findings)).toBeUndefined();
});
it("stays silent when the child is longer than the host", async () => {
const html = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="15">
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="30"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
expect(find(result.findings)).toBeUndefined();
});
it("stays silent for a non-sub-composition timed element", async () => {
const html = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
<div class="clip" data-start="0" data-duration="15"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
expect(find(result.findings)).toBeUndefined();
});
it("stays silent when the root has no numeric data-duration", async () => {
const html = `<html><body>
<div id="root" data-composition-id="main" data-start="0">
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="15"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
expect(find(result.findings)).toBeUndefined();
});
it("stays silent for a late-starting clip", async () => {
const html = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="40" data-duration="5"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
expect(find(result.findings)).toBeUndefined();
});
it("stays silent when an unknown-duration sibling covers the tail", async () => {
const html = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="60">
<div data-composition-id="sub" data-composition-src="compositions/sub.html" data-start="0" data-duration="15"></div>
<div class="clip" data-start="0"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" });
expect(find(result.findings)).toBeUndefined();
});
});
});
@@ -514,4 +514,66 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
}
return findings;
},
// subcomposition_blanks_before_host
// Warns when a full-bleed sub-composition slot ends before the host composition
// does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to
// the high-signal shape — a sole/dominant external mount starting at ~0 — so it
// stays silent on intentional short clips (an intro followed by other clips that
// carry the timeline forward).
// fallow-ignore-next-line complexity
({ tags, rootTag }) => {
if (!rootTag) return [];
const rootDuration = Number(readAttr(rootTag.raw, "data-duration"));
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
// Two independent knobs that happen to share a 0.5s magnitude. Tuned for
// real hosts (tens to hundreds of seconds); on a very short host (~6s) the
// EPSILON slack would let a ~10% blank tail pass unflagged — acceptable
// because the silent-blank trap this rule targets only matters at scale.
const EPSILON = 0.5; // seconds; tolerance for "ends/covers near the host end"
const START_TOLERANCE = 0.5; // seconds; "starts at the composition start"
const round3 = (n: number) => Math.round(n * 1000) / 1000;
// Timed children of the root. An element with data-start but no usable
// data-duration is treated as covering the tail (end = Infinity), so an
// unknown-length sibling suppresses the warning rather than triggering it.
const timed = tags
.filter((tag) => tag.index !== rootTag.index && readAttr(tag.raw, "data-start") !== null)
.map((tag) => {
const start = Number(readAttr(tag.raw, "data-start")) || 0;
const dur = Number(readAttr(tag.raw, "data-duration"));
const end = Number.isFinite(dur) && dur > 0 ? start + dur : Infinity;
return { tag, start, end };
});
// `tags` is a flat list (no nesting depth), so a timed element nested
// *inside* a candidate slot is treated as a tail-covering sibling rather
// than a descendant. Acceptable: external src mounts are empty by
// convention (content is loaded from the linked file), so the only
// false-negative path is rare and matches the flat-tag scope of the
// sibling rules in this file.
const tailCovered = (exceptIndex: number) =>
timed.some((t) => t.tag.index !== exceptIndex && t.end >= rootDuration - EPSILON);
const findings: HyperframeLintFinding[] = [];
for (const t of timed) {
if (readAttr(t.tag.raw, "data-composition-src") === null) continue; // external slot only
if (t.start > START_TOLERANCE) continue; // must start at the composition start
if (!Number.isFinite(t.end)) continue; // known, finite slot length
if (t.end >= rootDuration - EPSILON) continue; // already fills the host window
if (tailCovered(t.tag.index)) continue; // another clip covers the tail — not full-bleed
const elementId = readAttr(t.tag.raw, "id") || undefined;
const gap = round3(rootDuration - t.end);
findings.push({
code: "subcomposition_blanks_before_host",
severity: "warning",
message: `<${t.tag.name}${elementId ? ` id="${elementId}"` : ""}> sub-composition ends at ${round3(t.end)}s but the composition runs to ${round3(rootDuration)}s — its slot will be blank for ~${gap}s.`,
elementId,
fixHint: `data-duration is the slot's visible window. Set this sub-composition's data-duration to ${round3(rootDuration - t.start)} to fill the host window, or add another clip to cover the remaining ~${gap}s.`,
snippet: truncateSnippet(t.tag.raw),
});
}
return findings;
},
];
@@ -157,6 +157,13 @@ For the runtime end-to-end check (a fast `snapshot` pass + per-scene frame eyeba
**Do not** manually `master.add(child)` a sub-composition timeline into the host timeline. HyperFrames already drives them independently — nesting them in GSAP causes double-seeks.
### The host clip's `data-duration` is the slot's visible window
`data-duration` on the host clip defines **how long the slot is visible**, and it takes precedence over the sub-composition's internal GSAP timeline length. Two consequences follow:
- **Internal timeline shorter than the slot → the slot holds.** If the sub-composition's GSAP timeline finishes before `data-duration` elapses, the slot keeps showing its final frame for the rest of the window. You do **not** need to pad the timeline with empty tweens.
- **`data-duration` shorter than the host composition → the slot ends (and goes blank) when its own `data-duration` elapses.** This is intended: the clip is a fixed-length window on the timeline, not "fill until the composition ends." To keep a sub-composition visible for the whole composition, set its `data-duration` to span the host window (or add another clip to cover the remaining time). Leaving a single full-bleed sub-composition shorter than the composition is almost always a mistake — the linter flags it as `subcomposition_blanks_before_host`.
## Animations Inside Sub-Compositions
Prefer `gsap.fromTo()` over `gsap.from()` for entrance tweens. The host re-seeks the sub-composition every time its clip becomes visible; `gsap.from()` records the starting state at registration and can desync on seek-back, while `gsap.fromTo()` declares both endpoints explicitly and replays cleanly.