@@ -201,6 +201,149 @@ describe("composition rules", () => {
201201 } ) ;
202202 } ) ;
203203
204+ describe ( "root_composition_missing_html_wrapper" , ( ) => {
205+ it ( "flags bare composition div as error" , ( ) => {
206+ // Exact scenario from the screenshot — bare div with composition attributes, no HTML wrapper
207+ const html = `<div
208+ id="comp-main"
209+ data-composition-id="no-limits"
210+ data-start="0"
211+ data-duration="15"
212+ data-width="1920"
213+ data-height="1080"
214+ >
215+ <!-- Sub-composition: the visual spectacle -->
216+ <div
217+ id="el-visuals"
218+ data-composition-id="visuals"
219+ data-composition-src="compositions/visuals.html"
220+ data-duration="15"
221+ data-track-index="0"
222+ ></div>
223+
224+ <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
225+ <script>
226+ window.__timelines = window.__timelines || {};
227+ const tl = gsap.timeline({ paused: true });
228+ window.__timelines["no-limits"] = tl;
229+ </script>
230+ </div>` ;
231+ const result = lintHyperframeHtml ( html , { filePath : "index.html" } ) ;
232+ const finding = result . findings . find (
233+ ( f ) => f . code === "root_composition_missing_html_wrapper" ,
234+ ) ;
235+ expect ( finding ) . toBeDefined ( ) ;
236+ expect ( finding ?. severity ) . toBe ( "error" ) ;
237+ expect ( result . ok ) . toBe ( false ) ;
238+ } ) ;
239+
240+ it ( "does not flag properly wrapped HTML composition" , ( ) => {
241+ const html = `<!DOCTYPE html>
242+ <html><head><meta charset="UTF-8"></head><body>
243+ <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
244+ <div class="clip" data-start="0" data-duration="5">Hello</div>
245+ </div>
246+ <script>
247+ window.__timelines = window.__timelines || {};
248+ window.__timelines["main"] = gsap.timeline({ paused: true });
249+ </script>
250+ </body></html>` ;
251+ const result = lintHyperframeHtml ( html ) ;
252+ const finding = result . findings . find (
253+ ( f ) => f . code === "root_composition_missing_html_wrapper" ,
254+ ) ;
255+ expect ( finding ) . toBeUndefined ( ) ;
256+ } ) ;
257+
258+ it ( "does not flag composition starting with <html> (no doctype)" , ( ) => {
259+ const html = `<html><body>
260+ <div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
261+ <script>
262+ window.__timelines = window.__timelines || {};
263+ window.__timelines["main"] = gsap.timeline({ paused: true });
264+ </script>
265+ </body></html>` ;
266+ const result = lintHyperframeHtml ( html ) ;
267+ const finding = result . findings . find (
268+ ( f ) => f . code === "root_composition_missing_html_wrapper" ,
269+ ) ;
270+ expect ( finding ) . toBeUndefined ( ) ;
271+ } ) ;
272+
273+ it ( "does not flag sub-compositions" , ( ) => {
274+ const html = `<div data-composition-id="sub" data-width="1920" data-height="1080">
275+ <script>
276+ window.__timelines = window.__timelines || {};
277+ window.__timelines["sub"] = gsap.timeline({ paused: true });
278+ </script>
279+ </div>` ;
280+ const result = lintHyperframeHtml ( html , { isSubComposition : true } ) ;
281+ const finding = result . findings . find (
282+ ( f ) => f . code === "root_composition_missing_html_wrapper" ,
283+ ) ;
284+ expect ( finding ) . toBeUndefined ( ) ;
285+ } ) ;
286+
287+ it ( "does not flag HTML without composition attributes" , ( ) => {
288+ const html = `<div id="hello"><p>Not a composition</p></div>` ;
289+ const result = lintHyperframeHtml ( html ) ;
290+ const finding = result . findings . find (
291+ ( f ) => f . code === "root_composition_missing_html_wrapper" ,
292+ ) ;
293+ expect ( finding ) . toBeUndefined ( ) ;
294+ } ) ;
295+
296+ it ( "includes root tag snippet in finding" , ( ) => {
297+ const html = `<div data-composition-id="bare" data-width="1920" data-height="1080">
298+ <script>
299+ window.__timelines = window.__timelines || {};
300+ window.__timelines["bare"] = gsap.timeline({ paused: true });
301+ </script>
302+ </div>` ;
303+ const result = lintHyperframeHtml ( html ) ;
304+ const finding = result . findings . find (
305+ ( f ) => f . code === "root_composition_missing_html_wrapper" ,
306+ ) ;
307+ expect ( finding ) . toBeDefined ( ) ;
308+ expect ( finding ?. snippet ) . toContain ( "data-composition-id" ) ;
309+ } ) ;
310+ } ) ;
311+
312+ describe ( "standalone_composition_wrapped_in_template" , ( ) => {
313+ it ( "flags root index.html wrapped in template" , ( ) => {
314+ const html = `<template id="main-template">
315+ <div data-composition-id="main" data-width="1920" data-height="1080">
316+ <script>
317+ window.__timelines = window.__timelines || {};
318+ window.__timelines["main"] = gsap.timeline({ paused: true });
319+ </script>
320+ </div>
321+ </template>` ;
322+ const result = lintHyperframeHtml ( html ) ;
323+ const finding = result . findings . find (
324+ ( f ) => f . code === "standalone_composition_wrapped_in_template" ,
325+ ) ;
326+ expect ( finding ) . toBeDefined ( ) ;
327+ expect ( finding ?. severity ) . toBe ( "warning" ) ;
328+ } ) ;
329+
330+ it ( "does not flag sub-compositions in template" , ( ) => {
331+ const html = `<template id="sub-template">
332+ <div data-composition-id="sub" data-width="1920" data-height="1080">
333+ <script>
334+ window.__timelines = window.__timelines || {};
335+ window.__timelines["sub"] = gsap.timeline({ paused: true });
336+ </script>
337+ </div>
338+ </template>` ;
339+ const result = lintHyperframeHtml ( html , { isSubComposition : true } ) ;
340+ const finding = result . findings . find (
341+ ( f ) => f . code === "standalone_composition_wrapped_in_template" ,
342+ ) ;
343+ expect ( finding ) . toBeUndefined ( ) ;
344+ } ) ;
345+ } ) ;
346+
204347 describe ( "requestanimationframe_in_composition" , ( ) => {
205348 it ( "flags requestAnimationFrame usage in script content" , ( ) => {
206349 const html = `
0 commit comments