Skip to content

Commit 511665b

Browse files
vanceingallsclaude
andauthored
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>
1 parent 7010eda commit 511665b

3 files changed

Lines changed: 151 additions & 1 deletion

File tree

packages/core/src/lint/rules/adapters.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
import { describe, it, expect } from "vitest";
23
import { lintHyperframeHtml } from "../hyperframeLinter.js";
34

packages/core/src/lint/rules/gsap.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
import { describe, it, expect } from "vitest";
23
import { lintHyperframeHtml } from "../hyperframeLinter.js";
34

@@ -937,4 +938,104 @@ describe("GSAP rules", () => {
937938
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
938939
expect(finding).toBeUndefined();
939940
});
941+
942+
// gsap_studio_edit_blocked
943+
it("warns when script registers timeline AND has GSAP tweens targeting #id selectors", async () => {
944+
const html = `
945+
<html><body>
946+
<div data-composition-id="c1" data-width="1920" data-height="1080">
947+
<div id="headline" style="position:absolute;left:120px;top:200px;">Hello</div>
948+
</div>
949+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
950+
<script>
951+
window.__timelines = window.__timelines || {};
952+
const tl = gsap.timeline({ paused: true });
953+
tl.set("#headline", { opacity: 0 });
954+
tl.to("#headline", { opacity: 1, duration: 0.5 }, 0);
955+
window.__timelines["c1"] = tl;
956+
</script>
957+
</body></html>`;
958+
const result = await lintHyperframeHtml(html);
959+
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
960+
expect(finding).toBeDefined();
961+
expect(finding?.severity).toBe("warning");
962+
expect(finding?.message).toContain('"#headline"');
963+
});
964+
965+
it("warns when script registers timeline AND has GSAP tweens targeting .class selectors", async () => {
966+
const html = `
967+
<html><body>
968+
<div data-composition-id="c1" data-width="1920" data-height="1080">
969+
<div class="box" style="position:absolute;left:120px;top:200px;"></div>
970+
</div>
971+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
972+
<script>
973+
window.__timelines = window.__timelines || {};
974+
const tl = gsap.timeline({ paused: true });
975+
tl.from(".box", { y: 80, opacity: 0, duration: 0.4 }, 0);
976+
window.__timelines["c1"] = tl;
977+
</script>
978+
</body></html>`;
979+
const result = await lintHyperframeHtml(html);
980+
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
981+
expect(finding).toBeDefined();
982+
expect(finding?.message).toContain('".box"');
983+
});
984+
985+
it("does NOT warn when timeline is registered but no GSAP element selectors are called", async () => {
986+
const html = `
987+
<html><body>
988+
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
989+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
990+
<script>
991+
window.__timelines = window.__timelines || {};
992+
const tl = gsap.timeline({ paused: true });
993+
window.__timelines["c1"] = tl;
994+
</script>
995+
</body></html>`;
996+
const result = await lintHyperframeHtml(html);
997+
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
998+
expect(finding).toBeUndefined();
999+
});
1000+
1001+
it("does NOT warn when script has GSAP calls but does not register on window.__timelines", async () => {
1002+
const html = `
1003+
<html><body>
1004+
<div data-composition-id="c1" data-width="1920" data-height="1080">
1005+
<div id="box"></div>
1006+
</div>
1007+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
1008+
<script>
1009+
window.__timelines = window.__timelines || {};
1010+
const tl = gsap.timeline({ paused: true });
1011+
tl.to("#box", { x: 100, duration: 1 }, 0);
1012+
</script>
1013+
</body></html>`;
1014+
const result = await lintHyperframeHtml(html);
1015+
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
1016+
expect(finding).toBeUndefined();
1017+
});
1018+
1019+
it("lists all unique targeted selectors in the warning message", async () => {
1020+
const html = `
1021+
<html><body>
1022+
<div data-composition-id="c1" data-width="1920" data-height="1080">
1023+
<div id="title"></div>
1024+
<div id="sub"></div>
1025+
</div>
1026+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
1027+
<script>
1028+
window.__timelines = window.__timelines || {};
1029+
const tl = gsap.timeline({ paused: true });
1030+
tl.from("#title", { opacity: 0, duration: 0.3 }, 0);
1031+
tl.from("#sub", { opacity: 0, duration: 0.3 }, 0.2);
1032+
window.__timelines["c1"] = tl;
1033+
</script>
1034+
</body></html>`;
1035+
const result = await lintHyperframeHtml(html);
1036+
const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
1037+
expect(finding).toBeDefined();
1038+
expect(finding?.message).toContain('"#title"');
1039+
expect(finding?.message).toContain('"#sub"');
1040+
});
9401041
});

packages/core/src/lint/rules/gsap.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap
2222
import type { LintContext } from "../context";
2323
import type { HyperframeLintFinding, LintRule } from "../types";
2424
import type { OpenTag } from "../utils";
25-
import { readAttr, truncateSnippet, WINDOW_TIMELINE_ASSIGN_PATTERN } from "../utils";
25+
import {
26+
readAttr,
27+
truncateSnippet,
28+
WINDOW_TIMELINE_ASSIGN_PATTERN,
29+
TIMELINE_REGISTRY_ASSIGN_PATTERN,
30+
} from "../utils";
2631

2732
// ── GSAP-specific types ────────────────────────────────────────────────────
2833

@@ -47,6 +52,7 @@ const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
4752

4853
// ── GSAP parsing utilities ─────────────────────────────────────────────────
4954

55+
// fallow-ignore-next-line complexity
5056
function stripJsComments(source: string): string {
5157
let out = "";
5258
let i = 0;
@@ -161,6 +167,7 @@ function synthesizeWindowRaw(
161167
// parser already resolves variable targets (`tl.to(kicker, …)`) to selectors
162168
// and excludes non-DOM object-target anchors (`tl.to({ _: 0 }, …)`), so there's
163169
// no fragile positional pairing between a regex walk and the parsed list.
170+
// fallow-ignore-next-line complexity
164171
async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
165172
if (!/gsap\.timeline/.test(script)) return [];
166173
const parseGsapScript = await loadParseGsapScript();
@@ -334,6 +341,7 @@ function getSingleClassSelector(selector: string): string | null {
334341
return match?.groups?.name || null;
335342
}
336343

344+
// fallow-ignore-next-line complexity
337345
function cssTransformToGsapProps(cssTransform: string): string | null {
338346
const parts: string[] = [];
339347

@@ -374,8 +382,10 @@ function cssTransformToGsapProps(cssTransform: string): string | null {
374382

375383
// ── GSAP rules ─────────────────────────────────────────────────────────────
376384

385+
// fallow-ignore-next-line complexity
377386
export const gsapRules: LintRule<LintContext>[] = [
378387
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
388+
// fallow-ignore-next-line complexity
379389
async ({ source, tags, scripts, rootCompositionId }) => {
380390
const findings: HyperframeLintFinding[] = [];
381391

@@ -505,6 +515,7 @@ export const gsapRules: LintRule<LintContext>[] = [
505515
},
506516

507517
// gsap_css_transform_conflict
518+
// fallow-ignore-next-line complexity
508519
async ({ styles, scripts, tags }) => {
509520
const findings: HyperframeLintFinding[] = [];
510521
const cssTranslateSelectors = new Map<string, string>();
@@ -642,6 +653,7 @@ export const gsapRules: LintRule<LintContext>[] = [
642653
},
643654

644655
// audio_reactive_single_tween_per_group
656+
// fallow-ignore-next-line complexity
645657
({ scripts, styles }) => {
646658
const findings: HyperframeLintFinding[] = [];
647659
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
@@ -813,6 +825,7 @@ export const gsapRules: LintRule<LintContext>[] = [
813825
},
814826

815827
// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
828+
// fallow-ignore-next-line complexity
816829
async ({ styles, scripts, tags }) => {
817830
const findings: HyperframeLintFinding[] = [];
818831
const cssOpacityZeroSelectors = new Set<string>();
@@ -896,4 +909,39 @@ export const gsapRules: LintRule<LintContext>[] = [
896909
}
897910
return findings;
898911
},
912+
913+
// gsap_studio_edit_blocked
914+
// When a script both registers a timeline on window.__timelines AND contains
915+
// GSAP mutation calls targeting element selectors, Studio's isElementGsapTargeted
916+
// check returns true for those elements and silently skips saving drag/resize
917+
// position changes back to source HTML.
918+
({ scripts }) => {
919+
const findings: HyperframeLintFinding[] = [];
920+
const GSAP_MUTATION_SELECTOR_RE = /\.\s*(?:set|to|from|fromTo)\s*\(\s*["']([#.][^"']+)["']/g;
921+
922+
for (const script of scripts) {
923+
const content = stripJsComments(script.content);
924+
if (!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(content)) continue;
925+
926+
const targets = new Set<string>();
927+
let match: RegExpExecArray | null;
928+
const re = new RegExp(GSAP_MUTATION_SELECTOR_RE.source, "g");
929+
while ((match = re.exec(content)) !== null) {
930+
if (match[1]) targets.add(match[1]);
931+
}
932+
if (targets.size === 0) continue;
933+
934+
const selList = [...targets].map((s) => `"${s}"`).join(", ");
935+
findings.push({
936+
code: "gsap_studio_edit_blocked",
937+
severity: "warning",
938+
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.`,
939+
fixHint:
940+
"The hyperframes runtime registers timelines automatically. Do not add a manual window.__timelines script unless GSAP intentionally controls element positions. " +
941+
"For initial visibility states, use CSS (e.g. opacity:0) instead of gsap.set(). " +
942+
"If GSAP must own these elements' positions, avoid drag-editing them in Studio.",
943+
});
944+
}
945+
return findings;
946+
},
899947
];

0 commit comments

Comments
 (0)