fix(lint): stop erroring on the documented canonical clip block (#3374)

Linting the primitive-clip example from packages/core/docs/core.md produced
two errors against the docs' own linter:

    error  timed_element_missing_clip_class  el-3   <img data-start ...>
    error  self_closing_media_tag            el-4   <audio ... />

Both are now fixed, in opposite directions — one was the rule's fault, one was
the docs'.

`timed_element_missing_clip_class` claimed the element "will be visible for the
entire composition instead of only during its scheduled time range". That is
not what happens. `syncTimedElementVisibility` walks
`querySelectorAll("[data-start]")` and toggles `style.visibility` off the
ATTRIBUTE, with no reference to the class; the runtime's own init test pins it
with a bare `<div data-start data-duration>` carrying no `class="clip"`. Every
other consumer of the string "clip" — Studio's label derivation, the runtime's
timeline labels, core's selector helper — treats it as a name to skip, never as
a behaviour key. So the class is an authoring convention the tooling reads, not
the mechanism that hides the element.

The rule is therefore a warning rather than an error, and its message now says
what is actually true. `img` joins `audio` and `video` in skipTags: the three
media primitives sit on adjacent lines of the same documented clip block, all
three authored without `class="clip"`, and flagging only the `<img>` is what
made the documented pattern fail.

`self_closing_media_tag` was right and the docs were wrong: `/` is ignored on a
non-void element, so `<audio ... />` leaves the element open and everything
after it nests inside. Changed to `<audio ...></audio>`. The `<img ... />` on
the line above is a genuine void element and stays as it is.

The same false mechanism claim had been copied into the talking-head-recut
skill, in both the annotated example and the rules list, where agents read it
as fact. Corrected there too.

No effect on the 643 shipped registry files (this rule fires on none of them);
the change is to the documented pattern and to agent-authored compositions.
Regression test lints the canonical block verbatim and asserts it produces no
errors or warnings, so docs and linter cannot drift apart again silently.
This commit is contained in:
Miguel Ángel
2026-08-20 18:39:12 -04:00
committed by GitHub
parent f822200fb8
commit 2be5a03b80
5 changed files with 55 additions and 14 deletions
+2 -2
View File
@@ -117,7 +117,7 @@ Use the `data-composition-src` attribute to load a composition from an external
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video> <video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video> <video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." /> <img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." /> <audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..."></audio>
<!-- Load composition from external file --> <!-- Load composition from external file -->
<div <div
@@ -343,7 +343,7 @@ The top-level composition is the `index.html` entry point. It acts as the conduc
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video> <video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video> <video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." /> <img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." /> <audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..."></audio>
<!-- Load sub-compositions from external files --> <!-- Load sub-compositions from external files -->
<div <div
+30 -2
View File
@@ -445,7 +445,10 @@ describe("composition rules", () => {
const result = await lintHyperframeHtml(html); const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class"); const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
expect(finding).toBeDefined(); expect(finding).toBeDefined();
expect(finding?.severity).toBe("error"); // A warning, not an error: the runtime hides the element either way (see
// the message), so a missing marker class is an authoring-convention gap.
expect(finding?.severity).toBe("warning");
expect(finding?.message).not.toContain("visible for the entire composition");
}); });
it("does not flag element that has class='clip'", async () => { it("does not flag element that has class='clip'", async () => {
@@ -464,12 +467,16 @@ describe("composition rules", () => {
expect(finding).toBeUndefined(); expect(finding).toBeUndefined();
}); });
it("does not flag audio or video elements", async () => { it("does not flag the media primitives: audio, video, img", async () => {
// All three are authored without class="clip" in the canonical clip block
// (packages/core/docs/core.md). `img` used to be the only one of the three
// that errored, so the documented example failed its own linter.
const html = ` const html = `
<html><body> <html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"> <div data-composition-id="c1" data-width="1920" data-height="1080">
<audio data-start="0" data-duration="5" src="music.mp3"></audio> <audio data-start="0" data-duration="5" src="music.mp3"></audio>
<video data-start="0" data-duration="5" src="clip.mp4"></video> <video data-start="0" data-duration="5" src="clip.mp4"></video>
<img data-start="5" data-duration="4" src="still.png" />
</div> </div>
<script> <script>
window.__timelines = window.__timelines || {}; window.__timelines = window.__timelines || {};
@@ -481,6 +488,27 @@ describe("composition rules", () => {
expect(finding).toBeUndefined(); expect(finding).toBeUndefined();
}); });
it("leaves the documented canonical clip block completely clean", async () => {
// Verbatim from packages/core/docs/core.md. If this ever goes red again,
// the docs and the linter have drifted apart and one of them is wrong.
const html = `
<html><body>
<div id="comp-1" data-composition-id="my-video" data-width="1920" data-height="1080" data-start="0">
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="a.mp4" muted></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="a.png" />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="a.mp3"></audio>
</div>
<script src="gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["my-video"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const blocking = result.findings.filter((f) => f.severity !== "info");
expect(blocking.map((f) => `${f.severity}:${f.code}`)).toEqual([]);
});
it("does not flag element with only data-track-index (layer container, no timing)", async () => { it("does not flag element with only data-track-index (layer container, no timing)", async () => {
const html = ` const html = `
<html><body> <html><body>
+16 -4
View File
@@ -515,7 +515,12 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
({ tags }) => { ({ tags }) => {
const findings: HyperframeLintFinding[] = []; const findings: HyperframeLintFinding[] = [];
const skipTags = new Set(["audio", "video", "script", "style", "template"]); // `img` sits here for the same reason `video` and `audio` already did: the
// three media primitives are authored without `class="clip"` in the
// canonical clip block (packages/core/docs/core.md), so requiring it on the
// `<img>` alone errored on the documented pattern while its two siblings on
// the adjacent lines passed.
const skipTags = new Set(["audio", "img", "video", "script", "style", "template"]);
for (const tag of tags) { for (const tag of tags) {
if (skipTags.has(tag.name)) continue; if (skipTags.has(tag.name)) continue;
// Skip composition hosts // Skip composition hosts
@@ -534,11 +539,18 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const elementId = readAttr(tag.raw, "id") || undefined; const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({ findings.push({
code: "timed_element_missing_clip_class", code: "timed_element_missing_clip_class",
severity: "error", // Not an error: the runtime drives timed visibility off the `data-start`
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.`, // ATTRIBUTE, not this class — `syncTimedElementVisibility` walks
// `querySelectorAll("[data-start]")` and toggles `style.visibility`
// regardless of class (pinned by the runtime's own init test, which
// uses a bare `<div data-start data-duration>` with no `class="clip"`).
// The class is an authoring convention the tooling reads, so a missing
// one is worth flagging but does not break the render.
severity: "warning",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The runtime still hides it outside its time range, but Studio and the GSAP clip-ownership rules use .clip to recognise a clip, so leaving it off makes the element harder to edit and to lint.`,
elementId, elementId,
fixHint: fixHint:
'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.', 'Add class="clip" to the element so Studio and the linter can recognise it as a clip.',
snippet: truncateSnippet(tag.raw), snippet: truncateSnippet(tag.raw),
}); });
} }
+1 -1
View File
@@ -78,7 +78,7 @@
"files": 2 "files": 2
}, },
"talking-head-recut": { "talking-head-recut": {
"hash": "214eda4c0f2bedb1", "hash": "7ac85a5f44467d6b",
"files": 28 "files": 28
} }
} }
+6 -5
View File
@@ -950,10 +950,11 @@ ffmpeg -y -i "$VIDEO_PATH" -c:v libx264 -crf 18 -g 30 -keyint_min 30 \
<!-- Layer 2: each card-host sits at the bounds dictated by its layout. --> <!-- Layer 2: each card-host sits at the bounds dictated by its layout. -->
<!-- IMPORTANT: every card-host MUST carry BOTH "card-host" and "clip" classes. --> <!-- IMPORTANT: every card-host MUST carry BOTH "card-host" and "clip" classes. -->
<!-- - "card-host" → our positioning + pointer-events styles --> <!-- - "card-host" → our positioning + pointer-events styles -->
<!-- - "clip" → HyperFrames runtime uses this to enforce visibility --> <!-- - "clip" → the marker Studio and the linter use to recognise a -->
<!-- only during data-start … data-start+data-duration. --> <!-- clip. Visibility itself comes from data-start / -->
<!-- Without "clip" the host stays visible the whole video --> <!-- data-duration, which the runtime honours with or -->
<!-- (lint: timed_element_missing_clip_class). --> <!-- without this class -->
<!-- (lint: timed_element_missing_clip_class, a warning). -->
<!-- Example: card-01 with zone="fullscreen" → card-host covers (0,0,1920,1080) --> <!-- Example: card-01 with zone="fullscreen" → card-host covers (0,0,1920,1080) -->
<div <div
class="card-host clip" class="card-host clip"
@@ -1158,7 +1159,7 @@ decides where the actual visible card sits.
- Animate wrappers such as `#video-wrap`, not the video element dimensions directly. - Animate wrappers such as `#video-wrap`, not the video element dimensions directly.
- Avoid animating the same property on the same element from multiple timelines at the same time. - Avoid animating the same property on the same element from multiple timelines at the same time.
- Use `data-track-index`, not `data-layer`; use `data-duration`, not `data-end`. - Use `data-track-index`, not `data-layer`; use `data-duration`, not `data-end`.
- Every timed element (`card-host`, sub-composition, etc.) MUST include `class="clip"` alongside its own classes — e.g. `class="card-host clip"`. The HyperFrames runtime uses `.clip` to gate visibility to the `data-start … data-start+data-duration` window. Without it the element is visible for the whole video (lint: `timed_element_missing_clip_class`). - Every timed element (`card-host`, sub-composition, etc.) should include `class="clip"` alongside its own classes — e.g. `class="card-host clip"`. Visibility itself is driven by `data-start` / `data-duration`: the runtime gates every `[data-start]` element to its window whether or not this class is present. `.clip` is the marker Studio and the GSAP clip-ownership rules read to recognise a clip, so leaving it off makes the element harder to edit and to lint (lint: `timed_element_missing_clip_class`, a warning).
- For body / global `font-family`, list **concrete font names** (`'Inter', 'Caveat', …`) — not a CSS variable like `var(--font-family)`. The HyperFrames font resolver doesn't expand CSS vars during static analysis (lint: `font_family_without_font_face`). Cards may still use `var(--font-family)` internally since their `@font-face` declarations are loaded. - For body / global `font-family`, list **concrete font names** (`'Inter', 'Caveat', …`) — not a CSS variable like `var(--font-family)`. The HyperFrames font resolver doesn't expand CSS vars during static analysis (lint: `font_family_without_font_face`). Cards may still use `var(--font-family)` internally since their `@font-face` declarations are loaded.
### 10. Render to MP4 ### 10. Render to MP4