diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 81ca084f96..fdb182b7aa 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -219,10 +219,10 @@ const text = textContentFor(element, directOnly); if (!text) return false; if (directOnly) return true; - for (const child of Array.from(element.children)) { - if (isVisibleElement(child) && textContentFor(child)) return false; - } - return true; + // Aggregate text may come exclusively from descendants (including hidden + // captions). The container itself does not paint that text and must not be + // audited as though it did. + return textContentFor(element, true).length > 0; } function textClientRects(element, directOnly) { @@ -436,9 +436,9 @@ } function textOverflowIssues(element, root, rootRect, time, tolerance) { - const textRect = textRectFor(element); + const textRect = textRectFor(element, true); if (!textRect) return []; - const text = textContentFor(element); + const text = textContentFor(element, true); const selector = selectorFor(element); const issues = []; @@ -571,7 +571,7 @@ // (low colour alpha) is decorative and exempt, as are elements opted out with // data-layout-allow-overlap. function isSolidTextBlock(element) { - if (!isVisibleElement(element) || !hasOwnTextCandidate(element)) return false; + if (!isVisibleElement(element) || !hasOwnTextCandidate(element, true)) return false; if (hasAllowOverlapFlag(element)) return false; return colorAlpha(getComputedStyle(element).color) >= 0.35; } @@ -580,7 +580,7 @@ const blocks = []; for (const element of Array.from(root.querySelectorAll("*"))) { if (!isSolidTextBlock(element)) continue; - const rect = textRectFor(element); + const rect = textRectFor(element, true); if (rect) blocks.push({ element, rect }); } return blocks; @@ -983,9 +983,9 @@ function occludedTextIssue(element, time) { if (hasAllowOcclusionFlag(element)) return null; if (!hasVisibleTextInk(element)) return null; - const textRect = textRectFor(element); + const textRect = textRectFor(element, true); if (!textRect) return null; - const text = textContentFor(element); + const text = textContentFor(element, true); const { occluder, coveredFraction } = occlusionCoverage(element, textRect); if (!occluder) return null; if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null; @@ -1016,9 +1016,9 @@ // paints the glyphs; a `background-clip: text` with no gradient/image and no // opaque background-color paints nothing, so it stays reportable. function invisibleTextIssue(element, time) { - const textRect = textRectFor(element); + const textRect = textRectFor(element, true); if (!textRect) return null; - const text = textContentFor(element); + const text = textContentFor(element, true); if (!text) return null; const cs = getComputedStyle(element); // Vendor computed-style props are read by property (camelCase), matching @@ -1141,8 +1141,8 @@ if (escapedElements.has(element)) continue; // Ownership is geometric and strict-mutex: any text breach past canvas_overflow's own // tolerance cedes the element to canvas_overflow; in-bounds text leaves the panel finding. - if (hasOwnTextCandidate(element)) { - const textRect = textRectFor(element); + if (hasOwnTextCandidate(element, true)) { + const textRect = textRectFor(element, true); if (textRect && overflowFor(textRect, rootRect, tolerance)) continue; } const rect = toRect(element.getBoundingClientRect()); @@ -1372,7 +1372,7 @@ document.body; const rootRect = rootRectFor(root); const elements = Array.from(root.querySelectorAll("*")).filter((element) => - isVisibleElement(element), + isVisibleElement(element, 0.05), ); const issues = []; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 8494847fa4..c33059d749 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -242,6 +242,31 @@ describe("layout-audit.browser", () => { ]), ); }); + + it("does not expand a parent's overflow geometry to a positioned descendant", () => { + document.body.innerHTML = ` +
+
Visible copyPositioned copy
+
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + headline: rect({ left: 40, top: 60, width: 200, height: 40 }), + "positioned-copy": rect({ left: 700, top: 60, width: 160, height: 40 }), + headlineText: rect({ left: 40, top: 60, width: 120, height: 40 }), + "positioned-copyText": rect({ left: 700, top: 60, width: 160, height: 40 }), + text: rect({ left: 40, top: 60, width: 820, height: 40 }), + }, + { "positioned-copy": { position: "absolute" } }, + ); + installAuditScript(); + + const parentOverflow = runAudit().find( + (issue) => issue.code === "canvas_overflow" && issue.selector === "#headline", + ); + expect(parentOverflow).toBeUndefined(); + }); }); it("is inert unless text or media candidates are explicitly requested", () => { @@ -1265,7 +1290,11 @@ function auditOverlapScene(options: { selected = node; }, getClientRects() { - const id = (selected as Element | null)?.id ?? ""; + const element = + selected?.nodeType === Node.TEXT_NODE + ? selected.parentElement + : (selected as Element | null); + const id = element?.id ?? ""; return textRects[id] ? ([textRects[id]] as unknown as DOMRectList) : ([] as unknown as DOMRectList); @@ -1345,6 +1374,28 @@ describe("layout-audit.browser occlusion", () => { expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); }); + it("does not treat a visible container as painted text when its only text child is hidden", () => { + document.body.innerHTML = ` +
+
Hidden caption
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { + caption: { opacity: "0" }, + overlay: { backgroundColor: "rgb(10, 10, 10)" }, + }, + headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }), + topmostId: "overlay", + textRectElementId: "caption-container", + }); + installAuditScript(); + + const issues = runAudit(); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + }); + it("carries the fully-covered fraction when the occluder hits every probe point", () => { const occluded = auditOcclusionScene({ overlayStyle: { backgroundColor: "rgb(10, 10, 10)" }, @@ -1406,6 +1457,47 @@ describe("layout-audit.browser occlusion", () => { expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false); }); + it("audits only a container's direct text when a hidden descendant also has text", () => { + document.body.innerHTML = ` +
+
Visible copyHidden copy
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { + "hidden-copy": { opacity: "0" }, + overlay: { backgroundColor: "rgb(10, 10, 10)" }, + }, + headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }), + topmostId: "overlay", + }); + installAuditScript(); + const issue = runAudit().find((candidate) => candidate.code === "text_occluded"); + expect(issue?.text).toBe("Visible copy"); + }); + + it("does not expand a container's text audit to a positioned descendant", () => { + document.body.innerHTML = ` +
+
Visible copyPositioned copy
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { + "positioned-copy": { position: "absolute" }, + overlay: { backgroundColor: "rgb(10, 10, 10)" }, + }, + headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }), + topmostId: "overlay", + }); + installAuditScript(); + const issues = runAudit().filter((candidate) => candidate.code === "text_occluded"); + const headlineIssue = issues.find((candidate) => candidate.selector === "#headline"); + expect(headlineIssue?.text).toBe("Visible copy"); + }); + it("does not count a low-alpha gradient overlay (grid/scrim) as an opaque occluder", () => { const issues = auditOcclusionScene({ overlayStyle: { @@ -1476,6 +1568,7 @@ describe("layout-audit.browser occlusion", () => { }, headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }), topmostId: "overlay", + textRectElementId: "inner", }); installAuditScript(); expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(true); @@ -1643,6 +1736,7 @@ function installOcclusionGeometry(options: { styleOverrides: Record>>; headlineTextRect: DOMRect; topmostId: string; + textRectElementId?: string; }): void { const baseStyle: Record = { display: "block", @@ -1689,7 +1783,11 @@ function installOcclusionGeometry(options: { selected = node; }, getClientRects() { - return (selected as Element | null)?.id === "headline" + const selectedElement = + selected?.nodeType === Node.TEXT_NODE + ? (selected.parentElement as Element | null) + : (selected as Element | null); + return selectedElement?.id === (options.textRectElementId ?? "headline") ? ([options.headlineTextRect] as unknown as DOMRectList) : ([] as unknown as DOMRectList); }, @@ -1820,6 +1918,7 @@ async function runContrastAudit(): Promise>> { interface AuditIssue { code: string; selector: string; + text?: string; containerSelector?: string; overflow?: Record; message?: string; @@ -1836,6 +1935,20 @@ function runAudit(): AuditIssue[] { return audit({ time: 1, tolerance: 2 }); } +function selectedRangeElement(selected: Node | null): Element | null { + return selected?.nodeType === Node.TEXT_NODE + ? (selected.parentElement as Element | null) + : (selected as Element | null); +} + +function rangeTextRect(selected: Node | null, rects: Record): DOMRect | undefined { + const element = selectedRangeElement(selected); + if (element?.id === "ignored") return rects.ignored; + if (selected?.nodeType === Node.TEXT_NODE && element?.id) + return rects[`${element.id}Text`] ?? rects.text; + return rects.text; +} + function installGeometry( rects: Record, styleOverrides: Record> = {}, @@ -1890,8 +2003,7 @@ function installGeometry( selected = node; }, getClientRects() { - const element = selected as Element | null; - const textRect = element?.id === "ignored" ? rects.ignored : rects.text; + const textRect = rangeTextRect(selected, rects); return textRect ? ([textRect] as unknown as DOMRectList) : ([] as unknown as DOMRectList); }, detach() {}, diff --git a/packages/lint/src/context.ts b/packages/lint/src/context.ts index 9638f3ac52..85149c5532 100644 --- a/packages/lint/src/context.ts +++ b/packages/lint/src/context.ts @@ -3,7 +3,7 @@ import { parseHtmlStructure, findRootTag, collectCompositionIds, - readAttr, + readDecodedAttr, stripHtmlComments, } from "./utils"; import type { OpenTag, ExtractedBlock } from "./utils"; @@ -66,7 +66,7 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions const scripts = structure.scripts; const compositionIds = collectCompositionIds(tags); const rootTag = findRootTag(source, tags); - const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id"); + const rootCompositionId = readDecodedAttr(rootTag?.raw || "", "data-composition-id"); return { source, diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 60c90bd361..4ab12647b1 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -190,6 +190,89 @@ describe("composition rules", () => { }); }); + describe("duplicate_composition_id", () => { + it("flags a meta tag and root div sharing the same data-composition-id", async () => { + const html = ` + + + + + +
+ +`; + + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("does not flag a single valid composition id", async () => { + const html = ` + + +
+ +`; + + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); + expect(finding).toBeUndefined(); + }); + + it("does not flag distinct composition ids in one file", async () => { + const html = ` + + +
+
+
+ +`; + + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); + expect(finding).toBeUndefined(); + }); + + it("ignores composition ids inside inert template content", async () => { + const html = ` + +
+ +`; + + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); + expect(finding).toBeUndefined(); + }); + + it("flags entity-equivalent composition ids", async () => { + const html = ` + +
+ +`; + + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); + expect(finding).toBeDefined(); + }); + + it("uses the browser's first value for duplicate attributes", async () => { + const html = ` + +
+ +`; + + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); + expect(finding).toBeDefined(); + }); + }); + it("reports error when querySelector uses template literal variable", async () => { const html = ` diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index 0a52be380f..eee2ae9a88 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -2,6 +2,7 @@ import type { LintContext, HyperframeLintFinding, ExtractedBlock, OpenTag } from import { findHtmlTag, readAttr, + readDecodedAttr, readJsonAttr, stripJsComments, truncateSnippet, @@ -48,7 +49,7 @@ export function isRegistryInstalledFile(rawSource: string): boolean { function isCompositionRootOrMount(rawTag: string): boolean { return Boolean( - readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"), + readDecodedAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"), ); } @@ -156,7 +157,47 @@ function declaredIdsForBindingCheck(tags: readonly OpenTag[]): Set | nul return declared; } +function isInsideInertTemplate(tag: OpenTag, tags: readonly OpenTag[]): boolean { + return tags.some( + (candidate) => + candidate.name === "template" && + candidate.closeIndex != null && + tag.index > candidate.index && + tag.index < candidate.closeIndex, + ); +} + export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ + // duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries. + ({ tags }) => { + const tagsByCompositionId = new Map(); + for (const tag of tags) { + if (isInsideInertTemplate(tag, tags)) continue; + const compositionId = readDecodedAttr(tag.raw, "data-composition-id"); + if (!compositionId || compositionId.trim().length === 0) continue; + + const matchingTags = tagsByCompositionId.get(compositionId) ?? []; + matchingTags.push(tag.raw); + tagsByCompositionId.set(compositionId, matchingTags); + } + + const findings: HyperframeLintFinding[] = []; + for (const [compositionId, matchingTags] of tagsByCompositionId) { + if (matchingTags.length < 2) continue; + + findings.push({ + code: "duplicate_composition_id", + severity: "error", + message: `Composition id "${compositionId}" is used by ${matchingTags.length} elements. Each data-composition-id value must be unique within a composition file.`, + fixHint: + "Keep data-composition-id on exactly one element, the composition root. Remove it from metadata or duplicate hosts, especially a tag carrying the same data-composition-id as the root
, which causes a silent duplicate-id collision.", + snippet: truncateSnippet(matchingTags[0] ?? ""), + }); + } + + return findings; + }, + // invalid_parent_traversal_in_asset_path — catches `../` traversal in src, // href, inline-style url(), and + + + + +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined(); + expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined(); + expect(result.findings.find((f) => f.code === "head_leaked_text")).toBeUndefined(); + }); + it("reports error when timeline registry is missing", async () => { const html = ` @@ -778,6 +801,20 @@ body { expect(finding).toBeUndefined(); }); + it("matches timeline keys against browser-decoded composition ids", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined(); + }); + it("accepts object-literal timeline registration and extracts its keys", async () => { const html = ` diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index f4b6b8a300..16ae488966 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -2,6 +2,7 @@ import type { LintContext, HyperframeLintFinding } from "../context"; import postcss from "postcss"; import { readAttr, + readDecodedAttr, truncateSnippet, stripJsComments, extractCompositionIdsFromCss, @@ -40,7 +41,7 @@ function isStudioTimelineElement(tag: { raw: string; name: string }): boolean { function describeStudioElement(tag: { raw: string; name: string }): string { const parts = [`<${tag.name}`]; const className = readAttr(tag.raw, "class"); - const compositionId = readAttr(tag.raw, "data-composition-id"); + const compositionId = readDecodedAttr(tag.raw, "data-composition-id"); const dataStart = readAttr(tag.raw, "data-start"); const dataTrack = readAttr(tag.raw, "data-track-index") ?? readAttr(tag.raw, "data-track"); @@ -203,7 +204,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ // root_missing_composition_id + root_missing_dimensions ({ rootTag }) => { const findings: HyperframeLintFinding[] = []; - if (!rootTag || !readAttr(rootTag.raw, "data-composition-id")) { + if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) { findings.push({ code: "root_missing_composition_id", severity: "error", @@ -300,15 +301,10 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ }, // timeline_id_mismatch - ({ source }) => { + ({ source, compositionIds }) => { const findings: HyperframeLintFinding[] = []; - const htmlCompIds = new Set(); + const htmlCompIds = new Set(compositionIds); const timelineRegKeys = new Set(); - const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi; - let m: RegExpExecArray | null; - while ((m = compIdRe.exec(source)) !== null) { - if (m[1]) htmlCompIds.add(m[1]); - } for (const key of extractTimelineRegistryKeys(source)) { timelineRegKeys.add(key); } @@ -369,7 +365,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ for (const tag of tags) { const src = readAttr(tag.raw, "data-composition-src"); if (!src) continue; - if (readAttr(tag.raw, "data-composition-id")) continue; + if (readDecodedAttr(tag.raw, "data-composition-id")) continue; findings.push({ code: "host_missing_composition_id", severity: "error", @@ -452,8 +448,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ code: "studio_missing_editable_id", severity: "warning", message: `${descriptor} has no id, so Studio cannot use a stable edit target for its timeline and canvas controls.`, - selector: readAttr(tag.raw, "data-composition-id") - ? `[data-composition-id="${readAttr(tag.raw, "data-composition-id")}"]` + selector: readDecodedAttr(tag.raw, "data-composition-id") + ? `[data-composition-id="${readDecodedAttr(tag.raw, "data-composition-id")}"]` : undefined, fixHint: 'Add a stable, human-readable id such as id="hero-title" or id="scene-1-card" to every timeline-visible element you want agents or Studio to edit.', diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 5f9c93fcc8..3bbc59bc8f 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -998,6 +998,92 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it("does NOT report overlapping_gsap_tweens for distinct loop-built DOM targets", async () => { + const html = ` + +
+
+
+
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens"); + expect(finding).toBeUndefined(); + }); + + it("does NOT report overlapping_gsap_tweens for distinct object proxy drivers", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens"); + expect(finding).toBeUndefined(); + }); + + it("reports overlapping_gsap_tweens for the same object proxy driver", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens"); + expect(finding).toBeDefined(); + }); + + it("does not conflate same-named object proxies from sibling scopes", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens"); + expect(finding).toBeUndefined(); + }); + it("warns when an opacity exit ends at a clip start boundary without a hard kill", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index ec2e4c9b8a..df5f516c67 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1,6 +1,7 @@ interface LintParsedGsap { animations: Array<{ targetSelector: string; + targetIdentity?: string; method: string; position: number | string; properties: Record; @@ -27,6 +28,7 @@ import type { HyperframeLintFinding, LintRule } from "../types"; import type { OpenTag } from "../utils"; import { readAttr, + readDecodedAttr, truncateSnippet, stripJsComments, hasCaptionStyles, @@ -38,6 +40,7 @@ import { type GsapWindow = { targetSelector: string; + targetIdentity?: string; position: number; end: number; properties: string[]; @@ -61,6 +64,16 @@ const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05; // overlap analysis must never treat them as one. const UNRESOLVED_TARGET = "__unresolved__"; +// Parser labels for object-proxy tweens describe their role, not target +// identity. Two independent proxies can both be labelled `dwell/hold` (or the +// same driven DOM channel), so equality cannot prove they conflict. +function targetHasNoStableIdentity(selector: string, identity?: string): boolean { + if (identity) return false; + return ( + selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy → ") + ); +} + // ── GSAP parsing utilities ───────────────────────────────────────────────── function countClassUsage(tags: OpenTag[]): Map { @@ -137,6 +150,7 @@ async function extractGsapWindows(script: string): Promise { animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount; windows.push({ targetSelector: animation.targetSelector, + targetIdentity: animation.targetIdentity, position: animation.position, end: animation.position + effectiveDuration, properties: Object.keys(animation.properties), @@ -259,7 +273,7 @@ function findTagEnd(source: string, tag: OpenTag): number { function collectCompositionRanges(source: string, tags: OpenTag[]): CompositionRange[] { return tags .map((tag) => { - const id = readAttr(tag.raw, "data-composition-id"); + const id = readDecodedAttr(tag.raw, "data-composition-id"); if (!id) return null; return { id, @@ -593,12 +607,14 @@ export const gsapRules: LintRule[] = [ if (left.end <= left.position) continue; // Unresolved targets are unknown elements: two of them are not provably // the same element, so an overlap between them cannot be asserted. - if (left.targetSelector === UNRESOLVED_TARGET) continue; + if (targetHasNoStableIdentity(left.targetSelector, left.targetIdentity)) continue; for (let j = i + 1; j < gsapWindows.length; j++) { const right = gsapWindows[j]; if (!right) continue; if (right.end <= right.position) continue; - if (left.targetSelector !== right.targetSelector) continue; + const leftIdentity = left.targetIdentity ?? left.targetSelector; + const rightIdentity = right.targetIdentity ?? right.targetSelector; + if (leftIdentity !== rightIdentity) continue; const overlapStart = Math.max(left.position, right.position); const overlapEnd = Math.min(left.end, right.end); if (overlapEnd <= overlapStart) continue; diff --git a/packages/lint/src/rules/media.ts b/packages/lint/src/rules/media.ts index 1fa14d2496..8e14fc4df4 100644 --- a/packages/lint/src/rules/media.ts +++ b/packages/lint/src/rules/media.ts @@ -1,5 +1,5 @@ import type { LintContext, HyperframeLintFinding } from "../context"; -import { readAttr, truncateSnippet, isMediaTag } from "../utils"; +import { readAttr, readDecodedAttr, truncateSnippet, isMediaTag } from "../utils"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -308,7 +308,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = if (tag.name === "video" || tag.name === "audio") continue; if (voidElements.has(tag.name)) continue; // Skip the composition root — it uses data-start as a playback anchor, not as a clip timer - if (readAttr(tag.raw, "data-composition-id")) continue; + if (readDecodedAttr(tag.raw, "data-composition-id")) continue; if (readAttr(tag.raw, "data-start")) { timedTagPositions.push({ name: tag.name, diff --git a/packages/lint/src/rules/slideshow.ts b/packages/lint/src/rules/slideshow.ts index f435be33bf..41f2e86bcd 100644 --- a/packages/lint/src/rules/slideshow.ts +++ b/packages/lint/src/rules/slideshow.ts @@ -1,6 +1,6 @@ import type { LintContext, HyperframeLintFinding } from "../context"; import type { LintRule } from "../types"; -import { readAttr } from "../utils"; +import { readAttr, readDecodedAttr } from "../utils"; import { parseSlideshowManifest, resolveSlideshow, @@ -30,7 +30,7 @@ function parseTiming(raw: string): { start: number; duration: number } | null { function collectCompositionIdScenes(ctx: LintContext, seen: Set, out: Scene[]): void { for (const tag of ctx.tags) { - const compositionId = readAttr(tag.raw, "data-composition-id"); + const compositionId = readDecodedAttr(tag.raw, "data-composition-id"); if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId)) continue; const timing = parseTiming(tag.raw); diff --git a/packages/lint/src/utils.ts b/packages/lint/src/utils.ts index edb4b17e3a..39b2315381 100644 --- a/packages/lint/src/utils.ts +++ b/packages/lint/src/utils.ts @@ -121,7 +121,7 @@ export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): Op const bodyTag = tags.find((tag) => tag.name === "body"); if ( bodyTag && - (readAttr(bodyTag.raw, "data-composition-id") || + (readDecodedAttr(bodyTag.raw, "data-composition-id") || readAttr(bodyTag.raw, "data-width") || readAttr(bodyTag.raw, "data-height")) ) { @@ -148,7 +148,7 @@ export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): Op // still eligible as the root. if ( tag.name === "svg" && - !readAttr(tag.raw, "data-composition-id") && + !readDecodedAttr(tag.raw, "data-composition-id") && !readAttr(tag.raw, "data-width") && !readAttr(tag.raw, "data-height") ) { @@ -173,6 +173,22 @@ export function readAttr(tagSource: string, attr: string): string | null { return match?.[1] || null; } +/** Read an HTML attribute using browser-equivalent character-reference decoding. */ +export function readDecodedAttr(tagSource: string, attr: string): string | null { + if (!tagSource) return null; + let value: string | null = null; + const parser = new Parser( + { + onattribute(name, decodedValue) { + if (value === null && name.toLowerCase() === attr.toLowerCase()) value = decodedValue; + }, + }, + { decodeEntities: true, lowerCaseAttributeNames: false, lowerCaseTags: true }, + ); + parser.end(tagSource); + return value; +} + /** * Read an attribute that may legitimately contain the opposite quote * character. `readAttr` truncates `data-variable-values='{"title":"Hello"}'` @@ -200,7 +216,7 @@ export function readJsonAttr(tagSource: string, attr: string): string | null { export function collectCompositionIds(tags: OpenTag[]): Set { const ids = new Set(); for (const tag of tags) { - const compId = readAttr(tag.raw, "data-composition-id"); + const compId = readDecodedAttr(tag.raw, "data-composition-id"); if (compId) ids.add(compId); } return ids; diff --git a/packages/parsers/src/gsapInline.ts b/packages/parsers/src/gsapInline.ts index fa382ffa37..5989fc35c2 100644 --- a/packages/parsers/src/gsapInline.ts +++ b/packages/parsers/src/gsapInline.ts @@ -73,14 +73,20 @@ function collectPatternNames(pattern: Node, out: Set): void { else if (pattern?.type === "RestElement") collectPatternNames(pattern.argument, out); } +function boundPatterns(node: Node): Node[] { + if (isFunctionNode(node)) return node.params ?? []; + if (node.type === "VariableDeclarator") return [node.id]; + if (node.type === "CatchClause") return [node.param]; + if (node.type === "AssignmentExpression" && node.left?.type === "Identifier") return [node.left]; + return []; +} + /** Every identifier name bound anywhere inside the subtree (fn params, declared vars, catch params). */ function collectBoundNames(root: Node): Set { const names = new Set(); const visit = (node: Node): Node => { if (!isNode(node)) return node; - if (isFunctionNode(node)) for (const p of node.params ?? []) collectPatternNames(p, names); - else if (node.type === "VariableDeclarator") collectPatternNames(node.id, names); - else if (node.type === "CatchClause") collectPatternNames(node.param, names); + for (const pattern of boundPatterns(node)) collectPatternNames(pattern, names); transformChildren(node, visit); return node; }; @@ -339,8 +345,13 @@ function expandBody( ctx: ExpandCtx, ): Node[] { const block = substituteParams(cloneNode({ type: "BlockStatement", body: bodyStmts }), bindings); + tagProvenance(block, prov); tagTimelineCalls(block.body, prov, ctx); - return expandStatements(block.body, { ...ctx, depth: ctx.depth + 1 }); + block.body = expandStatements(block.body, { ...ctx, depth: ctx.depth + 1 }); + // Keep each synthetic expansion in its own lexical scope. Flattening repeated + // loop/helper bodies into Program scope makes same-named local DOM bindings + // overwrite one another during selector analysis. + return [block]; } function inlineHelper(call: Node, ctx: ExpandCtx): Node[] { diff --git a/packages/parsers/src/gsapParserAcorn.computed.test.ts b/packages/parsers/src/gsapParserAcorn.computed.test.ts index eb20ea757b..9666c4a2f0 100644 --- a/packages/parsers/src/gsapParserAcorn.computed.test.ts +++ b/packages/parsers/src/gsapParserAcorn.computed.test.ts @@ -20,6 +20,13 @@ describe("editabilityForProvenance", () => { const start = (a: { resolvedStart?: number }): number | undefined => a.resolvedStart; +function expectDistinctProxyIdentities(script: string): void { + const { animations } = parseGsapScriptAcorn(script); + expect(animations[0]?.targetIdentity).toBeDefined(); + expect(animations[1]?.targetIdentity).toBeDefined(); + expect(animations[0]?.targetIdentity).not.toBe(animations[1]?.targetIdentity); +} + describe("parseGsapScriptAcorn — computed timelines", () => { it("resolves an add-to-basket helper called twice (the reported case)", () => { const script = ` @@ -65,6 +72,140 @@ describe("parseGsapScriptAcorn — computed timelines", () => { expect(animations.map((a) => a.provenance?.kind)).toEqual(["loop", "loop", "loop"]); }); + it("keeps DOM target bindings distinct across bounded loop iterations", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + for (let i = 0; i < 2; i++) { + const card = document.getElementById("caption-card-" + i); + tl.to(card, { opacity: 1, duration: 1 }, i * 0.5); + } + `); + + expect(animations.map((animation) => animation.targetSelector)).toEqual([ + "#caption-card-0", + "#caption-card-1", + ]); + }); + + it("keeps var DOM target bindings visible outside ordinary blocks", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + if (true) { + var card = document.getElementById("caption-card"); + } + tl.to(card, { opacity: 1, duration: 1 }, 0); + `); + + expect(animations.map((animation) => animation.targetSelector)).toEqual(["#caption-card"]); + }); + + it("keeps an outer let DOM target binding after assignment inside a block", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + let card; + if (true) { + card = document.getElementById("caption-card"); + } + tl.to(card, { opacity: 1, duration: 1 }, 0); + `); + + expect(animations.map((animation) => animation.targetSelector)).toEqual(["#caption-card"]); + }); + + it("uses declaration-scoped identities for object proxy targets", () => { + expectDistinctProxyIdentities(` + const tl = gsap.timeline(); + (() => { + const driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, 0); + })(); + (() => { + const driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, 0); + })(); + `); + }); + + it("withholds object proxy identity when the binding is reassigned", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + let driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, 0); + driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, 0); + `); + + expect(animations.map((animation) => animation.targetIdentity)).toEqual([undefined, undefined]); + }); + + it("keeps helper-created object proxy instances distinct", () => { + expectDistinctProxyIdentities(` + const tl = gsap.timeline(); + function addDriver(at) { + const driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, at); + } + addDriver(0); + addDriver(0.5); + `); + }); + + it("keeps helper-created object proxy instances distinct across nested blocks", () => { + expectDistinctProxyIdentities(` + const tl = gsap.timeline(); + function addDriver(at) { + if (at >= 0) { + const driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, at); + } + } + addDriver(0); + addDriver(0.5); + `); + }); + + it("keeps helper-created var proxy instances distinct", () => { + expectDistinctProxyIdentities(` + const tl = gsap.timeline(); + function addDriver(at) { + var driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, at); + } + addDriver(0); + addDriver(0.5); + `); + }); + + it("keeps one outer object proxy stable across helper calls", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + const driver = { value: 0 }; + function animateDriver(at) { + tl.to(driver, { value: 1, duration: 1 }, at); + } + animateDriver(0); + animateDriver(0.5); + `); + + expect(animations[0]?.targetIdentity).toBeDefined(); + expect(animations[1]?.targetIdentity).toBe(animations[0]?.targetIdentity); + }); + + it("keeps parameter DOM target assignments visible outside nested blocks", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + function configure(card) { + if (true) { + card = document.getElementById("caption-card"); + } + tl.to(card, { opacity: 1, duration: 1 }, 0); + } + configure(null); + `); + + expect(animations.map((animation) => animation.targetSelector)).toEqual(["#caption-card"]); + }); + it("leaves a literal-position composition unchanged (regression)", () => { const { animations } = parseGsapScriptAcorn(` const tl = gsap.timeline(); diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index 048e0befd5..5e9b472bc3 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -39,6 +39,7 @@ const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]); const ITERATION_METHODS = new Set(["forEach", "map"]); const SCOPE_NODE_TYPES = new Set([ "Program", + "BlockStatement", "FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression", @@ -50,6 +51,19 @@ type ScopeBindings = ReadonlyMap; /** Per-scope element bindings: scopeNode → (variable name → selector). */ type TargetBindings = Map>; +type IdentifierDeclaration = { + node: any; + scopeNode: any; + expandedScopeNode?: any; + name: string; + kind: "const" | "let" | "var" | "param"; +}; + +type IdentifierBindingIndex = { + declarationsByName: Map; + reassignedDeclarations: Set; +}; + /** * Side-table of top-level const/let ARRAY and OBJECT literals (of literals), * captured by `collectScopeBindings` and stashed on the scope Map so that @@ -226,10 +240,16 @@ function selectorFromQueryCall(node: any, scope: ScopeBindings): string | null { * Return the nearest ancestor node whose type is in SCOPE_NODE_TYPES. * `ancestors` is the acorn-walk ancestor array (root→current, current is last). */ -function enclosingScopeNodeFromAncestors(ancestors: any[]): any { +function enclosingScopeNodeFromAncestors(ancestors: any[], includeBlocks = true): any { for (let i = ancestors.length - 2; i >= 0; i--) { const node = ancestors[i]; - if (node && SCOPE_NODE_TYPES.has(node.type)) return node; + if ( + node && + SCOPE_NODE_TYPES.has(node.type) && + (includeBlocks || node.type !== "BlockStatement") + ) { + return node; + } } return null; } @@ -244,6 +264,82 @@ function scopeChainFromAncestors(ancestors: any[]): any[] { return chain; } +function nearestExpandedScopeFromAncestors(ancestors: any[]): any | undefined { + for (let index = ancestors.length - 2; index >= 0; index--) { + const candidate = ancestors[index]; + if (candidate?.type === "BlockStatement" && readProvenance(candidate)) return candidate; + } + return undefined; +} + +function findVisibleIdentifierDeclaration( + name: string, + ancestors: any[], + index: IdentifierBindingIndex, + usageStart = Number.POSITIVE_INFINITY, +): IdentifierDeclaration | undefined { + const declarations = index.declarationsByName.get(name) ?? []; + const expandedScopeNode = nearestExpandedScopeFromAncestors(ancestors); + for (const scopeNode of scopeChainFromAncestors(ancestors)) { + const candidates = declarations + .filter( + (declaration) => + declaration.scopeNode === scopeNode && + (!declaration.expandedScopeNode || declaration.expandedScopeNode === expandedScopeNode) && + (declaration.kind === "var" || + declaration.kind === "param" || + declaration.node.start < usageStart), + ) + .sort((left, right) => right.node.start - left.node.start); + if (candidates[0]) return candidates[0]; + } + return undefined; +} + +function collectIdentifierBindingIndex(ast: any): IdentifierBindingIndex { + const declarationsByName = new Map(); + const reassignedDeclarations = new Set(); + + acornWalk.ancestor(ast, { + VariableDeclarator(node: any, _: unknown, ancestors: any[]) { + const name = node.id?.name; + if (!name) return; + const declaration = ancestors.at(-2); + const kind = declaration?.kind as "const" | "let" | "var" | undefined; + if (!kind) return; + const includeBlocks = declaration?.type !== "VariableDeclaration" || kind !== "var"; + const scopeNode = enclosingScopeNodeFromAncestors(ancestors, includeBlocks); + const expandedScopeNode = nearestExpandedScopeFromAncestors(ancestors); + const entries = declarationsByName.get(name) ?? []; + entries.push({ node, scopeNode, expandedScopeNode, name, kind }); + declarationsByName.set(name, entries); + }, + FunctionDeclaration: indexFunctionParameters, + FunctionExpression: indexFunctionParameters, + ArrowFunctionExpression: indexFunctionParameters, + } as any); + + const index = { declarationsByName, reassignedDeclarations }; + acornWalk.ancestor(ast, { + AssignmentExpression(node: any, _: unknown, ancestors: any[]) { + const name = node.left?.type === "Identifier" ? node.left.name : undefined; + if (!name) return; + const declaration = findVisibleIdentifierDeclaration(name, ancestors, index, node.start); + if (declaration) reassignedDeclarations.add(declaration.node); + }, + } as any); + return index; + + function indexFunctionParameters(node: any): void { + for (const parameter of node.params ?? []) { + if (parameter?.type !== "Identifier") continue; + const entries = declarationsByName.get(parameter.name) ?? []; + entries.push({ node: parameter, scopeNode: node, name: parameter.name, kind: "param" }); + declarationsByName.set(parameter.name, entries); + } + } +} + // ── Target bindings ─────────────────────────────────────────────────────────── function addBinding( @@ -334,7 +430,11 @@ function collectScopeBindings(ast: any): ScopeBindings { * Pass 1: direct DOM-lookup assignments. * Pass 2: forEach/map callback params whose collection's selector is known. */ -function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings { +function collectTargetBindings( + ast: any, + scope: ScopeBindings, + identifierBindings: IdentifierBindingIndex, +): TargetBindings { const bindings: TargetBindings = new Map(); acornWalk.ancestor(ast, { @@ -342,14 +442,35 @@ function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings { const name = node.id?.name; const selector = selectorFromQueryCall(node.init, scope); if (name && selector !== null) { - addBinding(bindings, enclosingScopeNodeFromAncestors(ancestors), name, selector); + const declaration = ancestors.at(-2); + const includeBlocks = + declaration?.type !== "VariableDeclaration" || declaration.kind !== "var"; + addBinding( + bindings, + enclosingScopeNodeFromAncestors(ancestors, includeBlocks), + name, + selector, + ); } }, AssignmentExpression(node: any, _: unknown, ancestors: any[]) { const left = node.left; const selector = selectorFromQueryCall(node.right, scope); if (left?.type === "Identifier" && selector !== null) { - addBinding(bindings, enclosingScopeNodeFromAncestors(ancestors), left.name, selector); + const declaration = findVisibleIdentifierDeclaration( + left.name, + ancestors, + identifierBindings, + node.start, + ); + addBinding( + bindings, + declaration?.scopeNode ?? + nearestExpandedScopeFromAncestors(ancestors) ?? + enclosingScopeNodeFromAncestors(ancestors), + left.name, + selector, + ); } }, } as any); @@ -1101,7 +1222,9 @@ function tweenCallToAnimation( call: TweenCallInfo, scope: ScopeBindings, source: string, + identifierBindings: IdentifierBindingIndex, ): Omit { + const provenance = readProvenance(call.node); const vars = objectExpressionToRecord(call.varsArg, scope, source); const properties: Record = {}; const extras: Record = {}; @@ -1201,9 +1324,34 @@ function tweenCallToAnimation( // Relabel object-proxy / empty-target tweens so they don't read as bare // __unresolved__: a dwell/hold spacer or an onUpdate-driven DOM channel (#5/#11). let selector = call.selector; + let targetIdentity: string | undefined; if (selector === "__unresolved__") { - const proxyLabel = describeProxyTarget(call.node.arguments?.[0], call.varsArg, scope); - if (proxyLabel) selector = proxyLabel; + const targetNode = call.node.arguments?.[0]; + const proxyLabel = describeProxyTarget(targetNode, call.varsArg, scope); + if (proxyLabel) { + selector = proxyLabel; + if (targetNode?.type === "Identifier") { + const declaration = findVisibleIdentifierDeclaration( + targetNode.name, + call.ancestors, + identifierBindings, + call.node.start, + ); + if ( + declaration?.node.init?.type === "ObjectExpression" && + !identifierBindings.reassignedDeclarations.has(declaration.node) + ) { + const declarationProvenance = + readProvenance(declaration.scopeNode) ?? readProvenance(declaration.expandedScopeNode); + const instanceIdentity = + declarationProvenance && + (declarationProvenance.kind === "helper" || declarationProvenance.kind === "loop") + ? `:${declarationProvenance.kind}:${declarationProvenance.callSite ?? ""}:${declarationProvenance.iteration ?? ""}` + : ""; + targetIdentity = `proxy:${targetNode.name}@${declaration.node.start}${instanceIdentity}`; + } + } + } } const anim: Omit = { @@ -1215,6 +1363,7 @@ function tweenCallToAnimation( duration, ease, }; + if (targetIdentity) anim.targetIdentity = targetIdentity; if (!hasPositionArg) anim.implicitPosition = true; let group = classifyTweenPropertyGroup(properties); if (!group && keyframesData) { @@ -1231,7 +1380,6 @@ function tweenCallToAnimation( if (motionPathResult) anim.arcPath = motionPathResult.arcPath; if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true; if (selector === "__unresolved__") anim.hasUnresolvedSelector = true; - const provenance = readProvenance(call.node); if (provenance) anim.provenance = provenance; return anim; } @@ -1670,13 +1818,16 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor locations: true, }); const scope = collectScopeBindings(ast); - const targetBindings = collectTargetBindings(ast, scope); + const identifierBindings = collectIdentifierBindingIndex(ast); + const targetBindings = collectTargetBindings(ast, scope, identifierBindings); const detection = findTimelineVar(ast, scope); const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; const timelineVar = timelineRootSource(ref, script); const calls = findAllTweenCalls(ast, ref, scope, targetBindings); sortBySourcePosition(calls); - const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); + const rawAnims = calls.map((call) => + tweenCallToAnimation(call, scope, script, identifierBindings), + ); applyTimelineDefaults(rawAnims, detection.defaults); resolveTimelinePositions(rawAnims); const animations = assignStableIds(rawAnims); @@ -1720,10 +1871,13 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap { /* fall back to current behavior */ } } - const targetBindings = collectTargetBindings(ast, scope); + const identifierBindings = collectIdentifierBindingIndex(ast); + const targetBindings = collectTargetBindings(ast, scope, identifierBindings); const calls = findAllTweenCalls(ast, ref, scope, targetBindings); sortBySourcePosition(calls); - const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); + const rawAnims = calls.map((call) => + tweenCallToAnimation(call, scope, script, identifierBindings), + ); applyTimelineDefaults(rawAnims, detection.defaults); // Seed tween start-keyframes from gsap.set()/tl.set() pre-states (read-only // enrichment; the write path keeps source untouched for round-trip parity). diff --git a/packages/parsers/src/gsapSerialize.ts b/packages/parsers/src/gsapSerialize.ts index 0595961bdd..d4080a5338 100644 --- a/packages/parsers/src/gsapSerialize.ts +++ b/packages/parsers/src/gsapSerialize.ts @@ -50,6 +50,8 @@ export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeE export interface GsapAnimation { id: string; targetSelector: string; + /** Stable parser-only identity for non-DOM targets whose display label is not unique. */ + targetIdentity?: string; method: GsapMethod; position: number | string; properties: Record;