diff --git a/packages/core/package.json b/packages/core/package.json index f8e286e35..6d38d1123 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,6 +21,10 @@ "import": "./src/index.ts", "types": "./src/index.ts" }, + "./assemble": { + "import": "./src/assemble/index.ts", + "types": "./src/assemble/index.ts" + }, "./lint": { "import": "./src/lint/index.ts", "types": "./src/lint/index.ts" @@ -52,6 +56,10 @@ "import": "./dist/index.js", "types": "./dist/index.d.ts" }, + "./assemble": { + "import": "./dist/assemble/index.js", + "types": "./dist/assemble/index.d.ts" + }, "./lint": { "import": "./dist/lint/index.js", "types": "./dist/lint/index.d.ts" diff --git a/packages/core/src/assemble/index.test.ts b/packages/core/src/assemble/index.test.ts new file mode 100644 index 000000000..0052d83a5 --- /dev/null +++ b/packages/core/src/assemble/index.test.ts @@ -0,0 +1,192 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { assembleScenes } from "./index"; + +function makeProject(): string { + const dir = mkdtempSync(join(tmpdir(), "hyperframes-assemble-")); + mkdirSync(join(dir, ".hyperframes", "scenes"), { recursive: true }); + writeFileSync( + join(dir, "index.html"), + `
+
+
+ + +
`, + "utf-8", + ); + return dir; +} + +function writeScene(dir: string, sceneNumber: number, title: string): void { + writeFileSync( + join(dir, ".hyperframes", "scenes", `scene${sceneNumber}.html`), + ` +

${title}

+ +.s${sceneNumber}-title { color: white; } + +var S${sceneNumber} = ${sceneNumber === 1 ? 0 : 4}; +tl.set(".s${sceneNumber}-title", { opacity: 0 }, 0); +tl.to(".s${sceneNumber}-title", { opacity: 1, duration: 0.3, ease: "power2.out" }, S${sceneNumber});`, + "utf-8", + ); +} + +describe("assembleScenes", () => { + it("injects scene HTML, CSS, and GSAP into scaffold markers", () => { + const dir = makeProject(); + writeScene(dir, 1, "One"); + writeScene(dir, 2, "Two"); + + const result = assembleScenes(dir); + + expect(result.ok).toBe(true); + expect(result.scenes).toBe(2); + const assembled = readFileSync(join(dir, "index.html"), "utf-8"); + expect(assembled).toContain(""); + expect(assembled).toContain('

One

'); + expect(assembled).toContain(".s2-title { color: white; }"); + expect(assembled).toContain('tl.to(".s2-title"'); + }); + + it("can re-run assembly without duplicating generated scene blocks", () => { + const dir = makeProject(); + writeScene(dir, 1, "One"); + writeScene(dir, 2, "Two"); + + expect(assembleScenes(dir).ok).toBe(true); + writeScene(dir, 1, "Updated"); + expect(assembleScenes(dir).ok).toBe(true); + + const assembled = readFileSync(join(dir, "index.html"), "utf-8"); + expect(assembled).toContain('

Updated

'); + expect(assembled).not.toContain('

One

'); + expect(assembled.match(/HYPERFRAMES GENERATED SCENE 1 HTML START/g)).toHaveLength(1); + expect(assembled.match(/HYPERFRAMES GENERATED SCENE STYLES START/g)).toHaveLength(1); + expect(assembled.match(/HYPERFRAMES GENERATED SCENE TWEENS START/g)).toHaveLength(1); + }); + + it("finds scene slots with extra classes and reordered attributes", () => { + const dir = makeProject(); + writeFileSync( + join(dir, "index.html"), + `
+
+
+ + +
`, + "utf-8", + ); + writeScene(dir, 1, "One"); + writeScene(dir, 2, "Two"); + + const result = assembleScenes(dir); + + expect(result.ok).toBe(true); + expect(readFileSync(join(dir, "index.html"), "utf-8")).toContain( + '

Two

', + ); + }); + + it("rejects standalone scene documents", () => { + const dir = makeProject(); + writeFileSync( + join(dir, ".hyperframes", "scenes", "scene1.html"), + ` + +

Broken

+ +h1 { color: white; } + +var S1 = 0;`, + "utf-8", + ); + writeFileSync( + join(dir, ".hyperframes", "scenes", "scene2.html"), + ` +

Two

+ +h1 { color: white; } + +var S2 = 4;`, + "utf-8", + ); + + const result = assembleScenes(dir, { dryRun: true }); + + expect(result.ok).toBe(false); + expect(result.errors.some((error) => error.message.includes("DOCTYPE"))).toBe(true); + }); + + it("rejects scene fragment contract violations", () => { + const dir = makeProject(); + writeFileSync( + join(dir, ".hyperframes", "scenes", "scene1.html"), + ` +
Broken
+ +body { margin: 0; } +.heading { transform: translate(-50%, -50%); } +#scene1 { opacity: 1; } + +tl.set(".heading", { opacity: 0 }); +tl.to(".heading", { opacity: 1, repeat: -1 }, 0);`, + "utf-8", + ); + writeScene(dir, 2, "Two"); + + const result = assembleScenes(dir, { dryRun: true }); + const messages = result.errors.map((error) => error.message).join("\n"); + + expect(result.ok).toBe(false); + expect(messages).toContain('HTML id "hero" must use "s1-" prefix'); + expect(messages).toContain("CSS must not style body"); + expect(messages).toContain("CSS must not set opacity on #scene1"); + expect(messages).toContain('GSAP section must define "var S1 = ;"'); + expect(messages).toContain('GSAP repeat value "-1" must be a finite non-negative number'); + expect(messages).toContain("tl.set() calls must use time 0"); + expect(messages).toContain("tl.to() calls must use S1"); + }); + + it("rejects direct gsap tween creators in scene fragments", () => { + const dir = makeProject(); + writeFileSync( + join(dir, ".hyperframes", "scenes", "scene1.html"), + ` +

Broken

+ +.s1-title { color: white; } + +var S1 = 0; +gsap.from(".s1-title", { opacity: 0, duration: 0.3 });`, + "utf-8", + ); + writeScene(dir, 2, "Two"); + + const result = assembleScenes(dir, { dryRun: true }); + const messages = result.errors.map((error) => error.message).join("\n"); + + expect(result.ok).toBe(false); + expect(messages).toContain("gsap.from(); use tl.set() and tl.to() instead"); + }); +}); diff --git a/packages/core/src/assemble/index.ts b/packages/core/src/assemble/index.ts new file mode 100644 index 000000000..d19ededef --- /dev/null +++ b/packages/core/src/assemble/index.ts @@ -0,0 +1,687 @@ +/** + * Deterministic multi-scene assembly. + * + * Reads a scaffold (`index.html`) and scene fragments + * (`.hyperframes/scenes/sceneN.html`), validates each fragment against the + * Scene Fragment Spec, splits on markers, and injects the pieces into the + * scaffold. + */ + +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +export interface AssembleResult { + ok: boolean; + errors: AssembleError[]; + lines: number; + scenes: number; + outputPath: string; +} + +export interface AssembleError { + file: string; + message: string; +} + +export interface AssembleOptions { + dryRun?: boolean; +} + +interface FragmentSections { + html: string; + css: string; + gsap: string; +} + +const HTML_MARKER = ""; +const CSS_MARKER = ""; +const GSAP_MARKER = ""; +const STYLE_MARKER = "/* SCENE STYLES */"; +const TWEENS_MARKER = "// SCENE TWEENS"; +const GENERATED_STYLES_START = "/* HYPERFRAMES GENERATED SCENE STYLES START */"; +const GENERATED_STYLES_END = "/* HYPERFRAMES GENERATED SCENE STYLES END */"; +const GENERATED_TWEENS_START = "// HYPERFRAMES GENERATED SCENE TWEENS START"; +const GENERATED_TWEENS_END = "// HYPERFRAMES GENERATED SCENE TWEENS END"; + +const PROHIBITED_PATTERNS: Array<[RegExp, string]> = [ + [/]/i, " tag; fragments must not be standalone documents"], + [/]/i, " tag; fragments must not be standalone documents"], + [/]/i, " tag; fragments must not be standalone documents"], + [/]/i, " tag; CSS section must be raw CSS"], + [/]/i, " tag; GSAP section must be raw JS"], + [/`; +} + +function createSceneHtmlEndMarker(sceneNumber: number): string { + return ``; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function splitTopLevelArgs(args: string): string[] { + const result: string[] = []; + let current = ""; + let depth = 0; + let quote = ""; + let escaped = false; + + for (const char of args) { + if (quote) { + current += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = ""; + } + continue; + } + + if (char === '"' || char === "'" || char === "`") { + quote = char; + current += char; + continue; + } + + if (char === "(" || char === "{" || char === "[") { + depth += 1; + current += char; + continue; + } + + if (char === ")" || char === "}" || char === "]") { + depth -= 1; + current += char; + continue; + } + + if (char === "," && depth === 0) { + result.push(current.trim()); + current = ""; + continue; + } + + current += char; + } + + if (current.trim()) result.push(current.trim()); + return result; +} + +function findMatchingParen(content: string, openParenIndex: number): number { + let depth = 0; + let quote = ""; + let escaped = false; + + for (let index = openParenIndex; index < content.length; index += 1) { + const char = content[index]; + + if (quote) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = ""; + } + continue; + } + + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + + if (char === "(") depth += 1; + if (char === ")") { + depth -= 1; + if (depth === 0) return index; + } + } + + return -1; +} + +function findTimelineCalls(gsap: string): TimelineCall[] { + const calls: TimelineCall[] = []; + const timelineCallPattern = /\btl\.(set|to)\s*\(/g; + let match: RegExpExecArray | null; + + while ((match = timelineCallPattern.exec(gsap)) !== null) { + const method = match[1]; + if (method !== "set" && method !== "to") continue; + const openParenIndex = gsap.indexOf("(", match.index); + const closeParenIndex = findMatchingParen(gsap, openParenIndex); + if (closeParenIndex === -1) { + calls.push({ method, args: [] }); + continue; + } + + calls.push({ + method, + args: splitTopLevelArgs(gsap.slice(openParenIndex + 1, closeParenIndex)), + }); + timelineCallPattern.lastIndex = closeParenIndex + 1; + } + + return calls; +} + +function getCssRules(css: string): CssRule[] { + const rules: CssRule[] = []; + const rulePattern = /([^{}]+)\{([^{}]*)\}/g; + let match: RegExpExecArray | null; + + while ((match = rulePattern.exec(css)) !== null) { + const rawSelector = match[1]; + const declarations = match[2]; + if (rawSelector === undefined || declarations === undefined) continue; + const selector = rawSelector.trim(); + if (!selector || selector.startsWith("@") || selector === "from" || selector === "to") { + continue; + } + + rules.push({ selector, declarations }); + } + + return rules; +} + +function getCssRulePropertyNames(declarations: string): Set { + const names = new Set(); + const propertyPattern = /(^|[;\s])([a-z-]+)\s*:/gi; + let match: RegExpExecArray | null; + + while ((match = propertyPattern.exec(declarations)) !== null) { + const propertyName = match[2]; + if (propertyName !== undefined) names.add(propertyName.toLowerCase()); + } + + return names; +} + +function getSelectorTokens(selector: string): string[] { + const tokens: string[] = []; + const tokenPattern = /[#.](-?[_a-zA-Z][\w-]*)/g; + let match: RegExpExecArray | null; + + while ((match = tokenPattern.exec(selector)) !== null) { + const token = match[1]; + if (token !== undefined) tokens.push(token); + } + + return tokens; +} + +function selectorContainsBodyElement(selector: string): boolean { + return selector.split(",").some((part) => /(^|[\s>+~])body(?=$|[\s.#:[>+~])/i.test(part.trim())); +} + +function validatePrefixedSelector( + selector: string, + sceneNumber: number, + file: string, + context: string, +): AssembleError[] { + const errors: AssembleError[] = []; + const prefix = `s${sceneNumber}-`; + + for (const token of getSelectorTokens(selector)) { + if (token.startsWith(prefix)) continue; + errors.push({ + file, + message: `${context} selector "${selector}" uses "${token}", expected "${prefix}" prefix`, + }); + } + + return errors; +} + +function validateHtmlPrefixes(html: string, sceneNumber: number, file: string): AssembleError[] { + const errors: AssembleError[] = []; + const prefix = `s${sceneNumber}-`; + const attrPattern = /\b(id|class)\s*=\s*(["'])(.*?)\2/gis; + let match: RegExpExecArray | null; + + while ((match = attrPattern.exec(html)) !== null) { + const attrName = match[1]?.toLowerCase(); + const attrValue = match[3]; + if (attrName === undefined || attrValue === undefined) continue; + const tokens = attrName === "class" ? attrValue.split(/\s+/) : [attrValue]; + for (const token of tokens) { + if (!token) continue; + if (!token.startsWith(prefix)) { + errors.push({ + file, + message: `HTML ${attrName} "${token}" must use "${prefix}" prefix`, + }); + } + } + } + + return errors; +} + +function validateCss( + css: string, + gsap: string, + sceneNumber: number, + file: string, +): AssembleError[] { + const errors: AssembleError[] = []; + const ownedSceneProperties = new Set([ + "position", + "top", + "left", + "width", + "height", + "opacity", + "z-index", + ]); + const animatedSelectors = new Set(); + + for (const call of findTimelineCalls(gsap)) { + const target = call.args[0] ?? ""; + for (const selector of getSelectorTokens(target)) animatedSelectors.add(selector); + } + + for (const rule of getCssRules(css)) { + errors.push(...validatePrefixedSelector(rule.selector, sceneNumber, file, "CSS")); + + if (selectorContainsBodyElement(rule.selector)) { + errors.push({ file, message: "CSS must not style body; the scaffold owns body styles" }); + } + + if (/(^|[^\w-])\.scene([^\w-]|$)/.test(rule.selector)) { + errors.push({ file, message: "CSS must not style .scene; the scaffold owns scene styles" }); + } + + if (new RegExp(`#scene${sceneNumber}(?![\\w-])`).test(rule.selector)) { + const propertyNames = getCssRulePropertyNames(rule.declarations); + for (const propertyName of propertyNames) { + if (ownedSceneProperties.has(propertyName)) { + errors.push({ + file, + message: `CSS must not set ${propertyName} on #scene${sceneNumber}; the scaffold owns scene containers`, + }); + } + } + } + + const hasCenteredTransform = /transform\s*:[^;{}]*translate\s*\(\s*-50%\s*,\s*-50%\s*\)/i.test( + rule.declarations, + ); + if (hasCenteredTransform) { + const centeredTokens = getSelectorTokens(rule.selector); + const animatesCenteredElement = centeredTokens.some((token) => animatedSelectors.has(token)); + if (animatesCenteredElement || centeredTokens.length === 0) { + errors.push({ + file, + message: + "CSS transform centering with translate(-50%, -50%) conflicts with GSAP transforms; use xPercent/yPercent in tl.set()", + }); + } + } + } + + return errors; +} + +function validateGsap(gsap: string, sceneNumber: number, file: string): AssembleError[] { + const errors: AssembleError[] = []; + const sceneVar = `S${sceneNumber}`; + const sceneVarPattern = new RegExp( + `^\\s*var\\s+${sceneVar}\\s*=\\s*-?\\d+(?:\\.\\d+)?\\s*;`, + "m", + ); + const repeatPattern = /\brepeat\s*:\s*([^,}\n]+)/g; + let repeatMatch: RegExpExecArray | null; + + if (!sceneVarPattern.test(gsap)) { + errors.push({ + file, + message: `GSAP section must define "var ${sceneVar} = ;"`, + }); + } + + while ((repeatMatch = repeatPattern.exec(gsap)) !== null) { + const rawValue = repeatMatch[1]; + if (rawValue === undefined) continue; + const value = rawValue.trim(); + const repeatValue = Number(value); + if (!/^\d+(?:\.\d+)?$/.test(value) || !Number.isFinite(repeatValue)) { + errors.push({ + file, + message: `GSAP repeat value "${value}" must be a finite non-negative number`, + }); + } + } + + for (const call of findTimelineCalls(gsap)) { + if (call.args.length === 0) { + errors.push({ file, message: `Unable to parse tl.${call.method}() call` }); + continue; + } + + const target = call.args[0] ?? ""; + errors.push(...validatePrefixedSelector(target, sceneNumber, file, "GSAP")); + + const position = call.args[2] ?? ""; + if (call.method === "set" && position !== "0") { + errors.push({ file, message: "tl.set() calls must use time 0 as the third argument" }); + } + + if (call.method === "to" && !new RegExp(`\\b${sceneVar}\\b`).test(position)) { + errors.push({ + file, + message: `tl.to() calls must use ${sceneVar} in the position argument`, + }); + } + } + + return errors; +} + +function validateFragment(content: string, file: string): AssembleError[] { + const errors: AssembleError[] = []; + const sceneNumber = getSceneNumber(file); + const markerCounts: Array<[string, number]> = [ + [HTML_MARKER, countMarker(content, HTML_MARKER)], + [CSS_MARKER, countMarker(content, CSS_MARKER)], + [GSAP_MARKER, countMarker(content, GSAP_MARKER)], + ]; + + for (const [marker, count] of markerCounts) { + if (count !== 1) { + errors.push({ file, message: `Expected 1 ${marker} marker, found ${count}` }); + } + } + + const htmlPosition = content.indexOf(HTML_MARKER); + const cssPosition = content.indexOf(CSS_MARKER); + const gsapPosition = content.indexOf(GSAP_MARKER); + if (htmlPosition >= 0 && cssPosition >= 0 && gsapPosition >= 0) { + if (!(htmlPosition < cssPosition && cssPosition < gsapPosition)) { + errors.push({ + file, + message: `Markers must appear in order: ${HTML_MARKER}, ${CSS_MARKER}, ${GSAP_MARKER}`, + }); + } + } + + for (const [pattern, reason] of PROHIBITED_PATTERNS) { + if (pattern.test(content)) { + errors.push({ file, message: `Prohibited: ${reason}` }); + } + } + + const hasValidMarkers = + markerCounts.every(([, count]) => count === 1) && + htmlPosition >= 0 && + cssPosition >= 0 && + gsapPosition >= 0 && + htmlPosition < cssPosition && + cssPosition < gsapPosition; + + if (hasValidMarkers && Number.isFinite(sceneNumber)) { + const sections = splitFragment(content); + errors.push(...validateHtmlPrefixes(sections.html, sceneNumber, file)); + errors.push(...validateCss(sections.css, sections.gsap, sceneNumber, file)); + errors.push(...validateGsap(sections.gsap, sceneNumber, file)); + } + + return errors; +} + +function splitFragment(content: string): FragmentSections { + const htmlStart = content.indexOf(HTML_MARKER) + HTML_MARKER.length; + const cssStart = content.indexOf(CSS_MARKER); + const gsapStart = content.indexOf(GSAP_MARKER); + + return { + html: content.slice(htmlStart, cssStart).trim(), + css: content.slice(cssStart + CSS_MARKER.length, gsapStart).trim(), + gsap: content.slice(gsapStart + GSAP_MARKER.length).trim(), + }; +} + +function fail(errors: AssembleError[]): AssembleResult { + return { + ok: false, + errors, + lines: 0, + scenes: 0, + outputPath: "", + }; +} + +function getSceneNumber(file: string): number { + return Number(file.match(/\d+/)?.[0] ?? Number.NaN); +} + +function getAttributeValue(tag: string, attrName: string): string | null { + const attrPattern = new RegExp(`\\b${attrName}\\s*=\\s*(["'])(.*?)\\1`, "i"); + const match = tag.match(attrPattern); + return match?.[2] ?? null; +} + +function getSceneOpenings(scaffold: string): SceneOpening[] { + const openings: SceneOpening[] = []; + const divPattern = /]*>/gi; + let match: RegExpExecArray | null; + + while ((match = divPattern.exec(scaffold)) !== null) { + const tag = match[0]; + const id = getAttributeValue(tag, "id"); + const className = getAttributeValue(tag, "class"); + const sceneNumber = Number(id?.match(/^scene(\d+)$/)?.[1] ?? Number.NaN); + const classTokens = className?.split(/\s+/) ?? []; + + if (Number.isFinite(sceneNumber) && classTokens.includes("scene")) { + openings.push({ + sceneNumber, + index: match.index, + endIndex: divPattern.lastIndex, + }); + } + } + + return openings; +} + +function getSceneSlots(scaffold: string): Set { + const sceneSlots = new Set(); + const openings = getSceneOpenings(scaffold); + + openings.forEach((opening, index) => { + const nextOpening = openings[index + 1]; + const marker = ``; + const sceneSegment = scaffold.slice(opening.endIndex, nextOpening?.index ?? scaffold.length); + if (sceneSegment.includes(marker)) sceneSlots.add(opening.sceneNumber); + }); + + return sceneSlots; +} + +function replaceGeneratedBlock( + output: string, + marker: string, + start: string, + end: string, + content: string, +): string { + const generatedBlock = `${start}\n${content}\n${end}`; + const existingBlockPattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`); + + if (existingBlockPattern.test(output)) { + return output.replace(existingBlockPattern, generatedBlock); + } + + return output.replace(marker, `${marker}\n${generatedBlock}`); +} + +export function assembleScenes(projectDir: string, options: AssembleOptions = {}): AssembleResult { + const dir = resolve(projectDir); + const indexPath = join(dir, "index.html"); + const scenesDir = join(dir, ".hyperframes", "scenes"); + + if (!existsSync(indexPath)) { + return fail([{ file: "index.html", message: "Scaffold not found" }]); + } + + if (!existsSync(scenesDir)) { + return fail([{ file: ".hyperframes/scenes/", message: "Scenes directory not found" }]); + } + + const scaffold = readFileSync(indexPath, "utf-8"); + if (!scaffold.includes(STYLE_MARKER)) { + return fail([{ file: "index.html", message: `Scaffold missing "${STYLE_MARKER}"` }]); + } + + if (!scaffold.includes(TWEENS_MARKER)) { + return fail([{ file: "index.html", message: `Scaffold missing "${TWEENS_MARKER}"` }]); + } + + const sceneSlots = getSceneSlots(scaffold); + if (sceneSlots.size === 0) { + return fail([ + { + file: "index.html", + message: + 'No scene content markers found. Expected:
', + }, + ]); + } + + const sceneFiles = readdirSync(scenesDir) + .filter((file) => /^scene\d+\.html$/.test(file)) + .sort((a, b) => getSceneNumber(a) - getSceneNumber(b)); + + if (sceneFiles.length === 0) { + return fail([{ file: ".hyperframes/scenes/", message: "No scene fragment files found" }]); + } + + const errors: AssembleError[] = []; + const fragments = new Map(); + + for (const file of sceneFiles) { + const sceneNumber = getSceneNumber(file); + const content = readFileSync(join(scenesDir, file), "utf-8"); + const fragmentErrors = validateFragment(content, file); + errors.push(...fragmentErrors); + + if (fragmentErrors.length === 0) { + fragments.set(sceneNumber, splitFragment(content)); + } + + if (!sceneSlots.has(sceneNumber)) { + errors.push({ file, message: `No matching scene slot in scaffold for scene ${sceneNumber}` }); + } + } + + for (const sceneNumber of sceneSlots) { + const sceneFile = `scene${sceneNumber}.html`; + const hasFileError = errors.some((error) => error.file === sceneFile); + if (!fragments.has(sceneNumber) && !hasFileError) { + errors.push({ + file: "index.html", + message: `Scene slot ${sceneNumber} has no fragment file`, + }); + } + } + + if (errors.length > 0) return fail(errors); + + let output = scaffold; + const sortedFragments = [...fragments.entries()].sort(([a], [b]) => a - b); + + for (const [sceneNumber, sections] of sortedFragments) { + const marker = ``; + output = replaceGeneratedBlock( + output, + marker, + createSceneHtmlStartMarker(sceneNumber), + createSceneHtmlEndMarker(sceneNumber), + sections.html, + ); + } + + const sceneCss = sortedFragments + .map(([sceneNumber, sections]) => `/* ===== SCENE ${sceneNumber} ===== */\n${sections.css}`) + .join("\n\n"); + output = replaceGeneratedBlock( + output, + STYLE_MARKER, + GENERATED_STYLES_START, + GENERATED_STYLES_END, + sceneCss, + ); + + const sceneGsap = sortedFragments + .map(([sceneNumber, sections]) => `// ===== SCENE ${sceneNumber} =====\n${sections.gsap}`) + .join("\n\n"); + output = replaceGeneratedBlock( + output, + TWEENS_MARKER, + GENERATED_TWEENS_START, + GENERATED_TWEENS_END, + sceneGsap, + ); + + const openingDivs = output.match(/]/g)?.length ?? 0; + const closingDivs = output.match(/<\/div>/g)?.length ?? 0; + if (openingDivs !== closingDivs) { + return fail([ + { + file: "assembled output", + message: `div imbalance: ${openingDivs} opening, ${closingDivs} closing`, + }, + ]); + } + + const lines = output.split("\n").length; + if (options.dryRun) { + return { ok: true, errors: [], lines, scenes: sortedFragments.length, outputPath: "" }; + } + + writeFileSync(indexPath, output, "utf-8"); + return { ok: true, errors: [], lines, scenes: sortedFragments.length, outputPath: indexPath }; +} diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index d4d1d6f45..42af74464 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -725,4 +725,300 @@ describe("GSAP rules", () => { const finding = result.findings.find((f) => f.code === "gsap_infinite_repeat"); expect(finding).toBeUndefined(); }); + + it("warns on tl.from() in multi-scene compositions", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "tl_from_in_multiscene"); + + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("warns on tl.fromTo() in multi-scene compositions", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "tl_from_in_multiscene"); + + expect(finding).toBeDefined(); + expect(finding?.message).toContain("tl.fromTo"); + }); + + it("does not warn on tl.from() in single-scene compositions", () => { + const html = ` + +
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "tl_from_in_multiscene"); + + expect(finding).toBeUndefined(); + }); + + it("does not warn on tl.from() targeting first-scene prefixed elements", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "tl_from_in_multiscene"); + + expect(finding).toBeUndefined(); + }); + + it("warns on scene 10 tl.from() when scene 1 is first", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "tl_from_in_multiscene"); + + expect(finding).toBeDefined(); + }); + + it("warns on tl.set with opacity: 0 after time 0 in multi-scene compositions", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "late_init_set"); + + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("warns on tl.set with opacity: 0 at a variable scene start", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "late_init_set"); + + expect(finding).toBeDefined(); + expect(finding?.message).toContain("S2"); + }); + + it("does not warn on tl.set with opacity: 0 at time 0", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "late_init_set"); + + expect(finding).toBeUndefined(); + }); + + it("does not warn on tl.set with opacity: 0 and no explicit position", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "late_init_set"); + + expect(finding).toBeUndefined(); + }); + + it("does not warn on tl.set with fractional opacity", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "late_init_set"); + + expect(finding).toBeUndefined(); + }); + + it("errors when scene container CSS overrides scaffold positioning", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "scene_position_override"); + + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.elementId).toBe("scene2"); + }); + + it("does not error when scene container CSS leaves position alone", () => { + const html = ` + +
+
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "scene_position_override"); + + expect(finding).toBeUndefined(); + }); + + it("does not error on scene container position in single-scene compositions", () => { + const html = ` + +
+
+
+ + +`; + + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "scene_position_override"); + + expect(finding).toBeUndefined(); + }); }); diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 8e83dbd42..479305069 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -1,7 +1,12 @@ import { parseGsapScript } from "../../parsers/gsapParser"; import type { LintContext, HyperframeLintFinding } from "../context"; import type { OpenTag } from "../utils"; -import { readAttr, truncateSnippet, WINDOW_TIMELINE_ASSIGN_PATTERN } from "../utils"; +import { + getSceneElements, + readAttr, + truncateSnippet, + WINDOW_TIMELINE_ASSIGN_PATTERN, +} from "../utils"; // ── GSAP-specific types ──────────────────────────────────────────────────── @@ -329,6 +334,96 @@ function getSingleClassSelector(selector: string): string | null { return match?.groups?.name || null; } +function getSceneNumberFromId(id: string): number | null { + const match = id.match(/^scene(\d+)$/i); + if (!match?.[1]) return null; + const value = Number(match[1]); + return Number.isFinite(value) ? value : null; +} + +function getFirstSceneNumber(sceneElements: OpenTag[]): number | null { + const sceneNumbers = sceneElements + .map((tag) => getSceneNumberFromId(readAttr(tag.raw, "id") || "")) + .filter((value) => value !== null); + if (sceneNumbers.length === 0) return null; + return Math.min(...sceneNumbers); +} + +function selectorTargetsSceneNumber(selector: string, sceneNumber: number): boolean { + return new RegExp(`(^|[\\s>+~,])#scene${sceneNumber}(?![\\w-])`).test(selector); +} + +function selectorUsesScenePrefix(selector: string, sceneNumber: number): boolean { + return new RegExp(`(^|[\\s>+~,])[#.]s${sceneNumber}-`).test(selector); +} + +function splitTopLevelArgs(args: string): string[] { + const result: string[] = []; + let current = ""; + let depth = 0; + let quote = ""; + let escaped = false; + + for (const char of args) { + if (quote) { + current += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = ""; + } + continue; + } + + if (char === '"' || char === "'" || char === "`") { + quote = char; + current += char; + continue; + } + + if (char === "(" || char === "{" || char === "[") { + depth += 1; + current += char; + continue; + } + + if (char === ")" || char === "}" || char === "]") { + depth -= 1; + current += char; + continue; + } + + if (char === "," && depth === 0) { + result.push(current.trim()); + current = ""; + continue; + } + + current += char; + } + + if (current.trim()) result.push(current.trim()); + return result; +} + +function getGsapPositionExpression(rawCall: string): string { + const openParenIndex = rawCall.indexOf("("); + const closeParenIndex = rawCall.lastIndexOf(")"); + if (openParenIndex === -1 || closeParenIndex === -1 || closeParenIndex <= openParenIndex) { + return ""; + } + + return splitTopLevelArgs(rawCall.slice(openParenIndex + 1, closeParenIndex))[2] ?? ""; +} + +function isZeroPositionExpression(positionExpression: string): boolean { + if (!positionExpression) return true; + const numeric = Number(positionExpression); + return Number.isFinite(numeric) && numeric === 0; +} + function cssTransformToGsapProps(cssTransform: string): string | null { const parts: string[] = []; @@ -744,11 +839,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ ({ scripts, tags }) => { const findings: HyperframeLintFinding[] = []; - // Detect multi-scene compositions: multiple elements with "scene" in their id - const sceneElements = tags.filter((t) => { - const id = readAttr(t.raw, "id") || ""; - return /^scene\d+$/i.test(id); - }); + const sceneElements = getSceneElements(tags); if (sceneElements.length < 2) return findings; for (const script of scripts) { @@ -779,4 +870,109 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ } return findings; }, + + // tl_from_in_multiscene + ({ scripts, tags }) => { + const findings: HyperframeLintFinding[] = []; + const sceneElements = getSceneElements(tags); + if (sceneElements.length < 2) return findings; + + const firstSceneNumber = getFirstSceneNumber(sceneElements); + + for (const script of scripts) { + for (const win of extractGsapWindows(script.content)) { + if (win.method !== "from" && win.method !== "fromTo") continue; + if ( + firstSceneNumber !== null && + (selectorTargetsSceneNumber(win.targetSelector, firstSceneNumber) || + selectorUsesScenePrefix(win.targetSelector, firstSceneNumber)) + ) { + continue; + } + + findings.push({ + code: "tl_from_in_multiscene", + severity: "warning", + message: + `tl.${win.method}("${truncateSnippet(win.targetSelector, 40)}") in a multi-scene composition. ` + + "Use tl.set() at time 0 to hide, then tl.to() to animate in. " + + "tl.from() and tl.fromTo() can flash elements before their entrance when seeking non-linearly.", + selector: win.targetSelector, + fixHint: + `Replace with: tl.set("${truncateSnippet(win.targetSelector, 30)}", { opacity: 0, ... }, 0); ` + + `tl.to("${truncateSnippet(win.targetSelector, 30)}", { opacity: 1, ... }, startTime);`, + snippet: truncateSnippet(win.raw), + }); + } + } + + return findings; + }, + + // late_init_set + ({ scripts, tags }) => { + const findings: HyperframeLintFinding[] = []; + const sceneElements = getSceneElements(tags); + if (sceneElements.length < 2) return findings; + + for (const script of scripts) { + for (const win of extractGsapWindows(script.content)) { + if (win.method !== "set") continue; + if (!zeroValue(win.propertyValues.opacity) && !zeroValue(win.propertyValues.autoAlpha)) { + continue; + } + if ("visibility" in win.propertyValues || "display" in win.propertyValues) continue; + + const positionExpression = getGsapPositionExpression(win.raw); + if (isZeroPositionExpression(positionExpression)) continue; + + findings.push({ + code: "late_init_set", + severity: "warning", + message: + `tl.set("${truncateSnippet(win.targetSelector, 40)}", { opacity: 0 }) at time ` + + `${positionExpression || win.position}s instead of 0. Elements must be hidden from time 0 ` + + "to prevent flashing when transitions reveal scenes early.", + selector: win.targetSelector, + fixHint: `Move to time 0: tl.set("${truncateSnippet(win.targetSelector, 30)}", { opacity: 0, ... }, 0);`, + snippet: truncateSnippet(win.raw), + }); + } + } + + return findings; + }, + + // scene_position_override + ({ styles, tags }) => { + const findings: HyperframeLintFinding[] = []; + const sceneElements = getSceneElements(tags); + if (sceneElements.length < 2) return findings; + + for (const style of styles) { + for (const tag of sceneElements) { + const sceneId = readAttr(tag.raw, "id"); + if (!sceneId) continue; + + const sceneRulePattern = new RegExp(`#${sceneId}\\s*\\{([^}]+)\\}`, "g"); + let match: RegExpExecArray | null; + while ((match = sceneRulePattern.exec(style.content)) !== null) { + const declarations = match[1] || ""; + const positionMatch = declarations.match(/position\s*:\s*(relative|static|fixed)/); + if (!positionMatch?.[1]) continue; + + findings.push({ + code: "scene_position_override", + severity: "error", + elementId: sceneId, + message: `#${sceneId} sets position: ${positionMatch[1]} — this overrides the scaffold's position: absolute and can push the scene off-screen.`, + fixHint: `Remove the position property from #${sceneId} CSS. The scaffold owns scene container positioning.`, + snippet: truncateSnippet(match[0]), + }); + } + } + } + + return findings; + }, ]; diff --git a/packages/core/src/lint/utils.ts b/packages/core/src/lint/utils.ts index 7ff05ec5f..baeb29d7d 100644 --- a/packages/core/src/lint/utils.ts +++ b/packages/core/src/lint/utils.ts @@ -114,6 +114,10 @@ export function isMediaTag(tagName: string): boolean { return tagName === "video" || tagName === "audio" || tagName === "img"; } +export function getSceneElements(tags: OpenTag[]): OpenTag[] { + return tags.filter((tag) => /^scene\d+$/i.test(readAttr(tag.raw, "id") || "")); +} + export function truncateSnippet(value: string, maxLength = 220): string | undefined { const normalized = value.replace(/\s+/g, " ").trim(); if (!normalized) return undefined; diff --git a/packages/core/src/studio-api/helpers/safePath.ts b/packages/core/src/studio-api/helpers/safePath.ts index 7a925c3c6..0c5c22b53 100644 --- a/packages/core/src/studio-api/helpers/safePath.ts +++ b/packages/core/src/studio-api/helpers/safePath.ts @@ -7,7 +7,7 @@ export function isSafePath(base: string, resolved: string): boolean { return resolved.startsWith(norm) || resolved === resolve(base); } -const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]); +const IGNORE_DIRS = new Set([".thumbnails", ".hyperframes", "node_modules", ".git"]); /** Recursively walk a directory and return relative file paths. */ export function walkDir(dir: string, prefix = ""): string[] { diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 807e6f288..aa9c747a0 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -1,6 +1,6 @@ --- name: hyperframes -description: Create video compositions, animations, title cards, overlays, captions, voiceovers, audio-reactive visuals, and scene transitions in HyperFrames HTML. Use when asked to build any HTML-based video content, add captions or subtitles synced to audio, generate text-to-speech narration, create audio-reactive animation (beat sync, glow, pulse driven by music), add animated text highlighting (marker sweeps, hand-drawn circles, burst lines, scribble, sketchout), or add transitions between scenes (crossfades, wipes, reveals, shader transitions). Covers composition authoring, timing, media, and the full video production workflow. For CLI commands (init, lint, preview, render, transcribe, tts) see the hyperframes-cli skill. +description: Create video compositions, animations, title cards, overlays, captions, voiceovers, audio-reactive visuals, multi-scene build pipelines, persistent-subject choreography, and scene transitions in HyperFrames HTML. Use when asked to build any HTML-based video content, add captions or subtitles synced to audio, generate text-to-speech narration, create audio-reactive animation (beat sync, glow, pulse driven by music), add animated text highlighting (marker sweeps, hand-drawn circles, burst lines, scribble, sketchout), build compositions with 2+ scenes, or add transitions between scenes (crossfades, wipes, reveals, shader transitions). Covers composition authoring, timing, media, scene-fragment assembly, and the full video production workflow. For CLI commands (init, lint, preview, render, transcribe, tts) see the hyperframes-cli skill. --- # HyperFrames @@ -42,6 +42,14 @@ Always run on every composition (except single-scene pieces and trivial edits). Read [references/prompt-expansion.md](references/prompt-expansion.md) for the full process and output format. +### Step 2b: Route multi-scene work + +For any composition with **2 or more scenes**, read [references/multi-scene.md](references/multi-scene.md) before planning or authoring. Follow that pipeline for scaffold creation, scene fragments, evaluation, assembly, and persistent elements. + +Before dispatching scene work, produce the blocking scene manifest described in `multi-scene.md`. That manifest is the single source of truth for scene numbers, starts, durations, transition timing, register, and persistent-subject reservations. + +The multi-scene reference overrides the generic entrance examples in this file for **scene fragments**: fragments use `tl.set()` at time 0 plus `tl.to()` at scene time. Do not use `gsap.from()` or `tl.from()` inside scene fragments. Single-scene compositions, standalone title cards, and small edits can use the simpler one-pass flow below. + ### Step 3: Plan Before writing HTML, think at a high level: @@ -71,7 +79,7 @@ Position every element where it should be at its **most visible moment** — the 1. **Identify the hero frame** for each scene — the moment when the most elements are simultaneously visible. This is the layout you build. 2. **Write static CSS** for that frame. The `.scene-content` container MUST fill the full scene using `width: 100%; height: 100%; padding: Npx;` with `display: flex; flex-direction: column; gap: Npx; box-sizing: border-box`. Use padding to push content inward — NEVER `position: absolute; top: Npx` on a content container. Absolute-positioned content containers overflow when content is taller than the remaining space. Reserve `position: absolute` for decoratives only. -3. **Add entrances with `gsap.from()`** — animate FROM offscreen/invisible TO the CSS position. The CSS position is the ground truth; the tween describes the journey to get there. (In sub-compositions loaded via `data-composition-src`, prefer `gsap.fromTo()` — see load-bearing GSAP rules in [references/motion-principles.md](references/motion-principles.md).) +3. **Add entrances from the static layout** — animate FROM offscreen/invisible TO the CSS position. The CSS position is the ground truth; the tween describes the journey to get there. In full standalone timelines, `gsap.from()` is usually the right tool. In multi-scene scene fragments, use the fragment-safe pattern from [references/multi-scene.md](references/multi-scene.md): `tl.set()` at time 0 plus `tl.to()` at scene time. In sub-compositions loaded via `data-composition-src`, prefer `gsap.fromTo()` — see load-bearing GSAP rules in [references/motion-principles.md](references/motion-principles.md). 4. **Add exits with `gsap.to()`** — animate TO offscreen/invisible FROM the CSS position. ### Example @@ -112,7 +120,7 @@ Position every element where it should be at its **most visible moment** — the ``` ```js -// Step 3: Animate INTO those positions +// Standalone or single-scene example: animate INTO those positions tl.from(".title", { y: 60, opacity: 0, duration: 0.6, ease: "power3.out" }, 0); tl.from(".subtitle", { y: 40, opacity: 0, duration: 0.5, ease: "power3.out" }, 0.2); tl.from(".logo", { scale: 0.8, opacity: 0, duration: 0.4, ease: "power2.out" }, 0.3); @@ -246,8 +254,8 @@ Video must be `muted playsinline`. Audio is always a separate `