mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting (#1345)
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches) * fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access - index.ts no longer exports document/session/history/persist-queue (those modules land in the next stacked PR); branch now typechecks standalone - setOwnText: optional-chain children[i] access (TS2532 under noUncheckedIndexedAccess) - fallow suppressions for buildPatchEvent + adapters/types.ts — consumers arrive in #1325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline - applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9 parser-backed ops instead of silently no-opping — callers must never believe an animation edit succeeded when nothing was mutated - validateOp returns false for Phase 3b ops so can() feature-detects - root package.json build filter now includes @hyperframes/sdk (package is dist-only; top-level build previously produced no SDK artifacts). publish.yml intentionally NOT updated — sdk stays unpublished until Phase 3 completes. Adversarial-review findings F3 + F4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs Round-2 review (Rames/Miguel) on the engine layer: - ORIGIN_APPLY_PATCHES: unique symbol → namespaced string ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't survive postMessage/structured-clone, which T3 embedded hosts may forward patch events across. Namespaced string keeps collision risk negligible. - setCompositionMetadata width/height: runtime treats data-width/data-height as a forced override of inline style (init.ts applyCompositionSizing). Style is always written; the data-* attr is updated when already present so the edit isn't clobbered on load. Absent attrs stay absent — inverses stay exact. Mirrored in the patch applier; 3 new tests. - JsonPatchOp documented as the emit-only RFC 6902 subset (add/remove/replace); applier header notes move/copy/test are ignored. - SdkDocument.html documented as a build-time snapshot (serialize() is the live state). - patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}. NOT changed (with reasons, see PR reply): moveElement left/top matches Studio's own inline-style commit convention (sourcePatcher); package version follows the repo-wide single-version policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): moveElement writes data-x/data-y, not left/top CSS HF elements use data-x/data-y for positioning (read by htmlParser.ts, emitted by hyperframes generator). CSS left/top is not the runtime convention. Adds inverse round-trip test for prior position restore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update bun.lock after sdk package registration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete * fix(sdk): address review — live-DOM query cache, single parse, style parse dedup - getElements/getElement/find now walk the live linkedom DOM via buildRoots with a lazily-built cache invalidated on dispatch/applyPatches — no serialize→ensureHfIds→parseHTML round trip per query - openComposition parses once (parseMutable); dropped discarded _doc constructor param and the redundant buildDocument call - document.ts buildElement reuses model.ts getElementStyles — removes duplicated parseInlineStyles (also fixes custom-prop camelCase mangling) - JSDoc note: empty batch() still fires change handlers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): restore full public exports now session/document modules exist index.ts re-exports document/session/history/persist-queue (trimmed in the engine-layer PR to keep it self-contained); drops the temporary fallow suppressions whose consumers now exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): coalesce history by patch paths; replay override-set on open Adversarial-review findings F1 + F2: - history: coalescing now requires identical patch paths in addition to op types + origin + window. Previously two rapid setStyle calls on DIFFERENT elements merged into one entry carrying the second forward + first inverse — undo then reverted the wrong element and stranded the latest edit. Slider drags on one property still coalesce. - T3 init: openComposition({ overrides }) now replays the stored override-set onto the freshly-parsed base before exposing the session (new keyToPath inverse mapping + applyOverrideSet). Previously the overrides were copied into the map but never applied — reopening an embedded composition showed and serialized the base template. - examples: GSAP calls now feature-detect with can() (Phase 3b ops throw UnsupportedOpError as of the engine-layer fix); UnsupportedOpError re-exported from the package entry. - 8 new session tests: coalesce same-path / cross-element / cross-prop, override round-trip (style/text/attr/timing/removal/restore-base). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify Round-2 review (Rames/Miguel) on the session layer: - batch() is now transactional: on throw, accumulated inverse patches are replayed in reverse and the override-set snapshot restored — the model is exactly as it was at batch entry. Previously a throwing batch left the DOM partially mutated with no patch trail, no history entry, no recovery path. 2 new tests (model unchanged + undo is no-op after throwing batch). - history coalesce key sorts opTypes — same op-type set coalesces regardless of dispatch order within a batch. - applyPatches comment documents that emitted PatchEvents carry an empty inversePatches array (hosts keep their own inverse log). - document.ts extractDimensions/extractDuration now use the engine's findRoot — dimension extraction and mutations agree on the root element ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's data-width/data-height forced-override attrs, falling back to inline style. - ownText documented: snapshot .text is trimmed display text; setText writes verbatim. Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush error surfacing, debounce window, path default, history ring-buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7010edac85
commit
511665b93a
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
@@ -937,4 +938,104 @@ describe("GSAP rules", () => {
|
||||
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
// gsap_studio_edit_blocked
|
||||
it("warns when script registers timeline AND has GSAP tweens targeting #id selectors", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="headline" style="position:absolute;left:120px;top:200px;">Hello</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set("#headline", { opacity: 0 });
|
||||
tl.to("#headline", { opacity: 1, duration: 0.5 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.message).toContain('"#headline"');
|
||||
});
|
||||
|
||||
it("warns when script registers timeline AND has GSAP tweens targeting .class selectors", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="box" style="position:absolute;left:120px;top:200px;"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from(".box", { y: 80, opacity: 0, duration: 0.4 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain('".box"');
|
||||
});
|
||||
|
||||
it("does NOT warn when timeline is registered but no GSAP element selectors are called", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT warn when script has GSAP calls but does not register on window.__timelines", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="box"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, duration: 1 }, 0);
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lists all unique targeted selectors in the warning message", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="title"></div>
|
||||
<div id="sub"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#title", { opacity: 0, duration: 0.3 }, 0);
|
||||
tl.from("#sub", { opacity: 0, duration: 0.3 }, 0.2);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain('"#title"');
|
||||
expect(finding?.message).toContain('"#sub"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,12 @@ async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap
|
||||
import type { LintContext } from "../context";
|
||||
import type { HyperframeLintFinding, LintRule } from "../types";
|
||||
import type { OpenTag } from "../utils";
|
||||
import { readAttr, truncateSnippet, WINDOW_TIMELINE_ASSIGN_PATTERN } from "../utils";
|
||||
import {
|
||||
readAttr,
|
||||
truncateSnippet,
|
||||
WINDOW_TIMELINE_ASSIGN_PATTERN,
|
||||
TIMELINE_REGISTRY_ASSIGN_PATTERN,
|
||||
} from "../utils";
|
||||
|
||||
// ── GSAP-specific types ────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,6 +52,7 @@ const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
|
||||
|
||||
// ── GSAP parsing utilities ─────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function stripJsComments(source: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
@@ -161,6 +167,7 @@ function synthesizeWindowRaw(
|
||||
// parser already resolves variable targets (`tl.to(kicker, …)`) to selectors
|
||||
// and excludes non-DOM object-target anchors (`tl.to({ _: 0 }, …)`), so there's
|
||||
// no fragile positional pairing between a regex walk and the parsed list.
|
||||
// fallow-ignore-next-line complexity
|
||||
async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
|
||||
if (!/gsap\.timeline/.test(script)) return [];
|
||||
const parseGsapScript = await loadParseGsapScript();
|
||||
@@ -334,6 +341,7 @@ function getSingleClassSelector(selector: string): string | null {
|
||||
return match?.groups?.name || null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function cssTransformToGsapProps(cssTransform: string): string | null {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -374,8 +382,10 @@ function cssTransformToGsapProps(cssTransform: string): string | null {
|
||||
|
||||
// ── GSAP rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const gsapRules: LintRule<LintContext>[] = [
|
||||
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
|
||||
// fallow-ignore-next-line complexity
|
||||
async ({ source, tags, scripts, rootCompositionId }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
@@ -505,6 +515,7 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
},
|
||||
|
||||
// gsap_css_transform_conflict
|
||||
// fallow-ignore-next-line complexity
|
||||
async ({ styles, scripts, tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const cssTranslateSelectors = new Map<string, string>();
|
||||
@@ -642,6 +653,7 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
},
|
||||
|
||||
// audio_reactive_single_tween_per_group
|
||||
// fallow-ignore-next-line complexity
|
||||
({ scripts, styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
|
||||
@@ -813,6 +825,7 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
},
|
||||
|
||||
// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
|
||||
// fallow-ignore-next-line complexity
|
||||
async ({ styles, scripts, tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const cssOpacityZeroSelectors = new Set<string>();
|
||||
@@ -896,4 +909,39 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// gsap_studio_edit_blocked
|
||||
// When a script both registers a timeline on window.__timelines AND contains
|
||||
// GSAP mutation calls targeting element selectors, Studio's isElementGsapTargeted
|
||||
// check returns true for those elements and silently skips saving drag/resize
|
||||
// position changes back to source HTML.
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const GSAP_MUTATION_SELECTOR_RE = /\.\s*(?:set|to|from|fromTo)\s*\(\s*["']([#.][^"']+)["']/g;
|
||||
|
||||
for (const script of scripts) {
|
||||
const content = stripJsComments(script.content);
|
||||
if (!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(content)) continue;
|
||||
|
||||
const targets = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(GSAP_MUTATION_SELECTOR_RE.source, "g");
|
||||
while ((match = re.exec(content)) !== null) {
|
||||
if (match[1]) targets.add(match[1]);
|
||||
}
|
||||
if (targets.size === 0) continue;
|
||||
|
||||
const selList = [...targets].map((s) => `"${s}"`).join(", ");
|
||||
findings.push({
|
||||
code: "gsap_studio_edit_blocked",
|
||||
severity: "warning",
|
||||
message: `GSAP tweens target ${selList} in a registered timeline. Studio cannot save drag/resize edits to these elements — the runtime skips write-back for any element that appears in a registered window.__timelines timeline.`,
|
||||
fixHint:
|
||||
"The hyperframes runtime registers timelines automatically. Do not add a manual window.__timelines script unless GSAP intentionally controls element positions. " +
|
||||
"For initial visibility states, use CSS (e.g. opacity:0) instead of gsap.set(). " +
|
||||
"If GSAP must own these elements' positions, avoid drag-editing them in Studio.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user