Skip to content

Commit 2be5a03

Browse files
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.
1 parent f822200 commit 2be5a03

5 files changed

Lines changed: 55 additions & 14 deletions

File tree

packages/core/docs/core.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ Use the `data-composition-src` attribute to load a composition from an external
117117
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
118118
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
119119
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
120-
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
120+
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..."></audio>
121121

122122
<!-- Load composition from external file -->
123123
<div
@@ -343,7 +343,7 @@ The top-level composition is the `index.html` entry point. It acts as the conduc
343343
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
344344
<video id="el-2" data-start="el-1" data-duration="8" data-track-index="0" src="..."></video>
345345
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
346-
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
346+
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..."></audio>
347347

348348
<!-- Load sub-compositions from external files -->
349349
<div

packages/lint/src/rules/composition.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,10 @@ describe("composition rules", () => {
445445
const result = await lintHyperframeHtml(html);
446446
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
447447
expect(finding).toBeDefined();
448-
expect(finding?.severity).toBe("error");
448+
// A warning, not an error: the runtime hides the element either way (see
449+
// the message), so a missing marker class is an authoring-convention gap.
450+
expect(finding?.severity).toBe("warning");
451+
expect(finding?.message).not.toContain("visible for the entire composition");
449452
});
450453

451454
it("does not flag element that has class='clip'", async () => {
@@ -464,12 +467,16 @@ describe("composition rules", () => {
464467
expect(finding).toBeUndefined();
465468
});
466469

467-
it("does not flag audio or video elements", async () => {
470+
it("does not flag the media primitives: audio, video, img", async () => {
471+
// All three are authored without class="clip" in the canonical clip block
472+
// (packages/core/docs/core.md). `img` used to be the only one of the three
473+
// that errored, so the documented example failed its own linter.
468474
const html = `
469475
<html><body>
470476
<div data-composition-id="c1" data-width="1920" data-height="1080">
471477
<audio data-start="0" data-duration="5" src="music.mp3"></audio>
472478
<video data-start="0" data-duration="5" src="clip.mp4"></video>
479+
<img data-start="5" data-duration="4" src="still.png" />
473480
</div>
474481
<script>
475482
window.__timelines = window.__timelines || {};
@@ -481,6 +488,27 @@ describe("composition rules", () => {
481488
expect(finding).toBeUndefined();
482489
});
483490

491+
it("leaves the documented canonical clip block completely clean", async () => {
492+
// Verbatim from packages/core/docs/core.md. If this ever goes red again,
493+
// the docs and the linter have drifted apart and one of them is wrong.
494+
const html = `
495+
<html><body>
496+
<div id="comp-1" data-composition-id="my-video" data-width="1920" data-height="1080" data-start="0">
497+
<video id="el-1" data-start="0" data-duration="10" data-track-index="0" src="a.mp4" muted></video>
498+
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="a.png" />
499+
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="a.mp3"></audio>
500+
</div>
501+
<script src="gsap.min.js"></script>
502+
<script>
503+
window.__timelines = window.__timelines || {};
504+
window.__timelines["my-video"] = gsap.timeline({ paused: true });
505+
</script>
506+
</body></html>`;
507+
const result = await lintHyperframeHtml(html);
508+
const blocking = result.findings.filter((f) => f.severity !== "info");
509+
expect(blocking.map((f) => `${f.severity}:${f.code}`)).toEqual([]);
510+
});
511+
484512
it("does not flag element with only data-track-index (layer container, no timing)", async () => {
485513
const html = `
486514
<html><body>

packages/lint/src/rules/composition.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,12 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
515515
// fallow-ignore-next-line complexity
516516
({ tags }) => {
517517
const findings: HyperframeLintFinding[] = [];
518-
const skipTags = new Set(["audio", "video", "script", "style", "template"]);
518+
// `img` sits here for the same reason `video` and `audio` already did: the
519+
// three media primitives are authored without `class="clip"` in the
520+
// canonical clip block (packages/core/docs/core.md), so requiring it on the
521+
// `<img>` alone errored on the documented pattern while its two siblings on
522+
// the adjacent lines passed.
523+
const skipTags = new Set(["audio", "img", "video", "script", "style", "template"]);
519524
for (const tag of tags) {
520525
if (skipTags.has(tag.name)) continue;
521526
// Skip composition hosts
@@ -534,11 +539,18 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
534539
const elementId = readAttr(tag.raw, "id") || undefined;
535540
findings.push({
536541
code: "timed_element_missing_clip_class",
537-
severity: "error",
538-
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.`,
542+
// Not an error: the runtime drives timed visibility off the `data-start`
543+
// ATTRIBUTE, not this class — `syncTimedElementVisibility` walks
544+
// `querySelectorAll("[data-start]")` and toggles `style.visibility`
545+
// regardless of class (pinned by the runtime's own init test, which
546+
// uses a bare `<div data-start data-duration>` with no `class="clip"`).
547+
// The class is an authoring convention the tooling reads, so a missing
548+
// one is worth flagging but does not break the render.
549+
severity: "warning",
550+
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.`,
539551
elementId,
540552
fixHint:
541-
'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.',
553+
'Add class="clip" to the element so Studio and the linter can recognise it as a clip.',
542554
snippet: truncateSnippet(tag.raw),
543555
});
544556
}

skills-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
"files": 2
7979
},
8080
"talking-head-recut": {
81-
"hash": "214eda4c0f2bedb1",
81+
"hash": "7ac85a5f44467d6b",
8282
"files": 28
8383
}
8484
}

skills/talking-head-recut/SKILL.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -950,10 +950,11 @@ ffmpeg -y -i "$VIDEO_PATH" -c:v libx264 -crf 18 -g 30 -keyint_min 30 \
950950
<!-- Layer 2: each card-host sits at the bounds dictated by its layout. -->
951951
<!-- IMPORTANT: every card-host MUST carry BOTH "card-host" and "clip" classes. -->
952952
<!-- - "card-host" → our positioning + pointer-events styles -->
953-
<!-- - "clip" → HyperFrames runtime uses this to enforce visibility -->
954-
<!-- only during data-start … data-start+data-duration. -->
955-
<!-- Without "clip" the host stays visible the whole video -->
956-
<!-- (lint: timed_element_missing_clip_class). -->
953+
<!-- - "clip" → the marker Studio and the linter use to recognise a -->
954+
<!-- clip. Visibility itself comes from data-start / -->
955+
<!-- data-duration, which the runtime honours with or -->
956+
<!-- without this class -->
957+
<!-- (lint: timed_element_missing_clip_class, a warning). -->
957958
<!-- Example: card-01 with zone="fullscreen" → card-host covers (0,0,1920,1080) -->
958959
<div
959960
class="card-host clip"
@@ -1158,7 +1159,7 @@ decides where the actual visible card sits.
11581159
- Animate wrappers such as `#video-wrap`, not the video element dimensions directly.
11591160
- Avoid animating the same property on the same element from multiple timelines at the same time.
11601161
- Use `data-track-index`, not `data-layer`; use `data-duration`, not `data-end`.
1161-
- 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`).
1162+
- 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).
11621163
- 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.
11631164

11641165
### 10. Render to MP4

0 commit comments

Comments
 (0)