From 511665b93ace7a22fa488d81365d229cba71c8c5 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 11 Jun 2026 12:23:36 -0700 Subject: [PATCH] feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting (#1345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * chore: update bun.lock after sdk package registration Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 * 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 * 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 * feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting --------- Co-authored-by: Claude Fable 5 --- packages/core/src/lint/rules/adapters.test.ts | 1 + packages/core/src/lint/rules/gsap.test.ts | 101 ++++++++++++++++++ packages/core/src/lint/rules/gsap.ts | 50 ++++++++- 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/packages/core/src/lint/rules/adapters.test.ts b/packages/core/src/lint/rules/adapters.test.ts index 938efc784..d28e49abc 100644 --- a/packages/core/src/lint/rules/adapters.test.ts +++ b/packages/core/src/lint/rules/adapters.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { describe, it, expect } from "vitest"; import { lintHyperframeHtml } from "../hyperframeLinter.js"; diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index 6ef3c57ea..7b89cc723 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -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 = ` + +
+
Hello
+
+ + +`; + 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 = ` + +
+
+
+ + +`; + 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 = ` + +
+ + +`; + 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 = ` + +
+
+
+ + +`; + 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 = ` + +
+
+
+
+ + +`; + 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"'); + }); }); diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index fb5d7b3f8..fe76ce280 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -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 { 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[] = [ // 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[] = [ }, // gsap_css_transform_conflict + // fallow-ignore-next-line complexity async ({ styles, scripts, tags }) => { const findings: HyperframeLintFinding[] = []; const cssTranslateSelectors = new Map(); @@ -642,6 +653,7 @@ export const gsapRules: LintRule[] = [ }, // 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[] = [ }, // 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(); @@ -896,4 +909,39 @@ export const gsapRules: LintRule[] = [ } 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(); + 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; + }, ];