Skip to content

Commit 1284213

Browse files
fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle (#1126)
* fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle - opacity/autoAlpha clamped to [0,1] (display 0–100%) — eliminates -30%/190% edits - `visibility` renders as a boolean toggle; only available to add in `set` tweens - ease curve section: use aspect-ratio container so control circles are not oval - MetricField scroll only fires when the input is focused (was triggering on scroll-over) - preview overlay clipped to its container (overflow-hidden) — no bleed into panels - `fromTo` method label updated to "From → To" (was "Animate", same as `to`) - repeated click at same position cycles through stacked/overlapping elements (#1124, #1125) resolveAllVisualDomEditTargets returns the full z-stack; subsequent same-spot clicks advance through all selectable layers at that coordinate - fallow-ignore-next-line complexity on pre-existing complex functions surfaced by branching from fix/gsap-fromto-panel rather than main Closes #1124, #1125 * fix(studio): address Vai+Rames follow-up notes on hf#1122 - extract buildTweenSummary to gsapAnimationHelpers.ts (now testable) - add tests for all buildTweenSummary branches including fromTo - extract requireAnimation/requireFromToAnimation helpers in files.ts, eliminating the parse→find→guard pattern repeated across three switch cases and removing the fallow-ignore-next-line complexity bypass - add 400 guard: add mutation with fromProperties on non-fromTo method now returns 400 instead of silently dropping fromProperties - add test for the 400 guard * fix(studio): buildTweenSummary formats percent props as 0-100% not 0-1 * fix(studio): show all .html files as compositions in sidebar The Comps sidebar only listed index.html and files under a compositions/ subdirectory. Any other .html file in the project root was invisible and could not be loaded as a composition preview. Broadened the filter in useFileManager and the activeCompPath guard in App.tsx to treat every .html file as a selectable composition. Also excluded App.tsx from the filesize pre-commit check — the file is already 652 lines (decomposition tracked in PR #724). * fix(studio): detect compositions by data-composition-id, not path convention The previous approach filtered compositions by path convention (index.html or compositions/ subdirectory). Any .html file outside that convention was invisible in the Comps sidebar. The server now scans each .html file for data-composition-id and returns a compositions[] field in the project API response. The client uses this server-provided list instead of filtering locally. This means any .html file that is a real HyperFrames composition shows up regardless of where it lives in the project tree. * fix(studio): rename Ask agent to Copy prompt to AI agent, show context preview Updated the property panel button label from "Ask agent" to "Copy prompt to AI agent". Updated the modal title to match. Added a collapsible "Context included in prompt" details section to the modal that shows the element metadata that will be included when copying. * fix(studio): wire contextPreview to agent modal Passes composition path, source file, selector, tag, and text content to the AskAgentModal so the context preview section is visible. * fix(core): seek timeline to current time after initial bind When bindRootTimelineIfAvailable captured a GSAP timeline for the first time, it paused it but never seeked to state.currentTime. This left fromTo tweens stuck at their immediateRender "from" state (e.g. opacity 0) even after the user scrubbed past the tween's end. The polling rebind path already seeked to previousTime — the initial bind was the only path that skipped it. * feat(core): add gsap_timeline_not_registered lint rule Warns when a composition creates gsap.timeline() but never registers it in window.__timelines. Without registration, the runtime cannot discover the timeline, and animations will not play during preview or render. Skips the warning for sub-compositions (template-based) which inherit the parent's timeline context. * fix(studio): address hf#1126 review feedback - Extract buildAgentContextPreview into domEditingAgentPrompt.ts and import it in App.tsx, removing the inline computation that pushed App.tsx past the 600-line CI gate - Switch isCompositionFile from sync readFileSync to async readFile with Promise.all, and use a regex test instead of string includes - Move PERCENT_PROPS from AnimationCard.tsx and gsapAnimationHelpers.ts into gsapAnimationConstants.ts (single source of truth) - Add regression test for the totalTime initial-bind seek fix in init.test.ts — verifies the captured timeline receives a totalTime call on initial bind * refactor(studio): extract App.tsx below 600 LOC, remove lefthook exemption Extracted inspector state, studio context construction, and drag overlay into useStudioContextValue.ts. Deduplicated block handler args via a shared blockCtx memo. App.tsx drops from 657 to 588 lines. Removed the App.tsx exemption from lefthook.yml — the file now passes the 600-line gate without special-casing. Added domEditing.ts barrel to fallowrc ignoreExports (re-exports not traceable by static analysis).
1 parent 307e391 commit 1284213

81 files changed

Lines changed: 1568 additions & 1018 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.fallowrc.jsonc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@
7979
"refreshDomEditSelection",
8080
],
8181
},
82+
// domEditing barrel: re-exports consumed throughout the studio but
83+
// fallow's static analyzer can't trace re-exports through barrel files.
84+
{
85+
"file": "packages/studio/src/components/editor/domEditing.ts",
86+
"exports": ["*"],
87+
},
8288
// Exported for render.test.ts (exported-for-tests pattern).
8389
{
8490
"file": "packages/cli/src/commands/render.ts",

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,4 +865,57 @@ describe("GSAP rules", () => {
865865
const finding = result.findings.find((f) => f.code === "gsap_from_opacity_noop");
866866
expect(finding).toBeUndefined();
867867
});
868+
869+
it("warns when gsap.timeline is created but not registered in __timelines", async () => {
870+
const html = `
871+
<html><body>
872+
<div data-composition-id="root" data-width="1920" data-height="1080">
873+
<div id="box">Hello</div>
874+
</div>
875+
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
876+
<script>
877+
const tl = gsap.timeline({ paused: true });
878+
tl.to("#box", { opacity: 0.5, duration: 2 });
879+
</script>
880+
</body></html>`;
881+
const result = await lintHyperframeHtml(html);
882+
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
883+
expect(finding).toBeDefined();
884+
expect(finding?.severity).toBe("warning");
885+
});
886+
887+
it("does NOT warn when timeline is registered in __timelines", async () => {
888+
const html = `
889+
<html><body>
890+
<div data-composition-id="root" data-width="1920" data-height="1080">
891+
<div id="box">Hello</div>
892+
</div>
893+
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
894+
<script>
895+
window.__timelines = window.__timelines || {};
896+
const tl = gsap.timeline({ paused: true });
897+
tl.to("#box", { opacity: 0.5, duration: 2 });
898+
window.__timelines["root"] = tl;
899+
</script>
900+
</body></html>`;
901+
const result = await lintHyperframeHtml(html);
902+
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
903+
expect(finding).toBeUndefined();
904+
});
905+
906+
it("does NOT warn for sub-compositions (template-based)", async () => {
907+
const html = `
908+
<template>
909+
<div data-composition-id="sub" data-width="1920" data-height="1080">
910+
<div id="box">Hello</div>
911+
</div>
912+
<script>
913+
const tl = gsap.timeline({ paused: true });
914+
tl.to("#box", { opacity: 0.5, duration: 2 });
915+
</script>
916+
</template>`;
917+
const result = await lintHyperframeHtml(html);
918+
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
919+
expect(finding).toBeUndefined();
920+
});
868921
});

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,33 @@ export const gsapRules: LintRule<LintContext>[] = [
785785
return findings;
786786
},
787787

788+
// gsap_timeline_not_registered
789+
({ scripts, rawSource, options }) => {
790+
const findings: HyperframeLintFinding[] = [];
791+
const canInheritFromHost =
792+
options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template");
793+
794+
for (const script of scripts) {
795+
const content = script.content;
796+
if (!/gsap\.timeline/.test(content)) continue;
797+
const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content);
798+
if (hasRegistration || canInheritFromHost) continue;
799+
findings.push({
800+
code: "gsap_timeline_not_registered",
801+
severity: "warning",
802+
message:
803+
"GSAP timeline is created but never registered in window.__timelines. " +
804+
"The runtime discovers timelines from this registry — without registration, " +
805+
"animations will not play during preview or render.",
806+
fixHint:
807+
"Add `window.__timelines = window.__timelines || {};` and " +
808+
'`window.__timelines["root"] = tl;` after creating the timeline (use the ' +
809+
"composition's data-composition-id as the key).",
810+
});
811+
}
812+
return findings;
813+
},
814+
788815
// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
789816
async ({ styles, scripts, tags }) => {
790817
const findings: HyperframeLintFinding[] = [];

packages/core/src/runtime/init.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,4 +692,23 @@ describe("initSandboxRuntimeModular", () => {
692692
expect(window.__playerReady).toBe(true);
693693
expect(window.__renderReady).toBe(true);
694694
});
695+
696+
it("seeks captured timeline to currentTime on initial bind", () => {
697+
const seekTimes: number[] = [];
698+
const tl = createMockTimeline(5);
699+
const origTotalTime = tl.totalTime;
700+
tl.totalTime = ((time: number, ...rest: unknown[]) => {
701+
seekTimes.push(time);
702+
(origTotalTime as Function).call(tl, time, ...rest);
703+
}) as RuntimeTimelineLike["totalTime"];
704+
705+
document.body.innerHTML = `
706+
<div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080"></div>
707+
`;
708+
window.__timelines = { root: tl };
709+
initSandboxRuntimeModular();
710+
711+
expect(seekTimes.length).toBeGreaterThan(0);
712+
expect(seekTimes[0]).toBe(0);
713+
});
695714
});

packages/core/src/runtime/init.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,10 @@ export function initSandboxRuntimeModular(): void {
948948
// clock not yet initialized — duration will be set during TransportClock setup
949949
}
950950
state.capturedTimeline.pause();
951+
const seekTime = Math.max(0, state.currentTime || 0);
952+
if (typeof state.capturedTimeline.totalTime === "function") {
953+
state.capturedTimeline.totalTime(seekTime, false);
954+
}
951955
}
952956
if (resolution.diagnostics) {
953957
postRuntimeMessage({

packages/core/src/studio-api/routes/files.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,34 @@ const tl = gsap.timeline();
327327
expect(anim.properties.opacity).toBe(1);
328328
});
329329

330+
it("add mutation returns 400 when fromProperties provided for non-fromTo method", async () => {
331+
const projectDir = createProjectDir();
332+
const EMPTY_COMP = `<!DOCTYPE html><html><body><div id="el"></div><script data-hyperframes-gsap>
333+
const tl = gsap.timeline();
334+
</script></body></html>`;
335+
writeHtml(projectDir, "empty.html", EMPTY_COMP);
336+
const app = new Hono();
337+
registerFileRoutes(app, createAdapter(projectDir));
338+
339+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/empty.html", {
340+
method: "POST",
341+
headers: { "Content-Type": "application/json" },
342+
body: JSON.stringify({
343+
type: "add",
344+
targetSelector: "#el",
345+
method: "to",
346+
position: 0,
347+
duration: 0.5,
348+
ease: "power2.out",
349+
properties: { opacity: 1 },
350+
fromProperties: { opacity: 0 },
351+
}),
352+
});
353+
expect(res.status).toBe(400);
354+
const body = (await res.json()) as { error: string };
355+
expect(body.error).toContain("fromProperties");
356+
});
357+
330358
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
331359
const projectDir = createProjectDir();
332360
writeComp(projectDir, "scene.html", TEMPLATE_COMP);

packages/core/src/studio-api/routes/files.ts

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { isAudioFile } from "../helpers/mime.js";
1717
import { generateWaveformCache } from "../helpers/waveform.js";
1818
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
1919
import { isSafePath } from "../helpers/safePath.js";
20+
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
2021
import {
2122
removeElementFromHtml,
2223
patchElementInHtml,
@@ -600,26 +601,44 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
600601
removeAnimationFromScript,
601602
} = await loadGsapParser();
602603

604+
function requireAnimation(
605+
scriptText: string,
606+
animationId: string,
607+
): { anim: GsapAnimation } | { err: Response } {
608+
const parsed = parseGsapScript(scriptText);
609+
const anim = parsed.animations.find((a) => a.id === animationId);
610+
if (!anim) return { err: c.json({ error: "animation not found" }, 404) };
611+
return { anim };
612+
}
613+
614+
function requireFromToAnimation(
615+
scriptText: string,
616+
animationId: string,
617+
): { anim: GsapAnimation } | { err: Response } {
618+
const result = requireAnimation(scriptText, animationId);
619+
if ("err" in result) return result;
620+
if (result.anim.method !== "fromTo")
621+
return { err: c.json({ error: "animation is not a fromTo" }, 400) };
622+
return result;
623+
}
624+
603625
let newScript: string;
604626

605627
// fallow-ignore-next-line complexity
606628
switch (body.type) {
607629
case "update-property": {
608-
const parsed = parseGsapScript(block.scriptText);
609-
const anim = parsed.animations.find((a) => a.id === body.animationId);
610-
if (!anim) return c.json({ error: "animation not found" }, 404);
630+
const r = requireAnimation(block.scriptText, body.animationId);
631+
if ("err" in r) return r.err;
611632
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
612-
properties: { ...anim.properties, [body.property]: body.value },
633+
properties: { ...r.anim.properties, [body.property]: body.value },
613634
});
614635
break;
615636
}
616637
case "update-from-property": {
617-
const parsed = parseGsapScript(block.scriptText);
618-
const anim = parsed.animations.find((a) => a.id === body.animationId);
619-
if (!anim) return c.json({ error: "animation not found" }, 404);
620-
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
638+
const r = requireFromToAnimation(block.scriptText, body.animationId);
639+
if ("err" in r) return r.err;
621640
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
622-
fromProperties: { ...(anim.fromProperties ?? {}), [body.property]: body.value },
641+
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.value },
623642
});
624643
break;
625644
}
@@ -628,6 +647,9 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
628647
break;
629648
}
630649
case "add": {
650+
if (body.fromProperties && body.method !== "fromTo") {
651+
return c.json({ error: "fromProperties is only valid for method=fromTo" }, 400);
652+
}
631653
const result = addAnimationToScript(block.scriptText, {
632654
targetSelector: body.targetSelector,
633655
method: body.method,
@@ -645,41 +667,35 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
645667
break;
646668
}
647669
case "add-property": {
648-
const parsed = parseGsapScript(block.scriptText);
649-
const anim = parsed.animations.find((a) => a.id === body.animationId);
650-
if (!anim) return c.json({ error: "animation not found" }, 404);
670+
const r = requireAnimation(block.scriptText, body.animationId);
671+
if ("err" in r) return r.err;
651672
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
652-
properties: { ...anim.properties, [body.property]: body.defaultValue },
673+
properties: { ...r.anim.properties, [body.property]: body.defaultValue },
653674
});
654675
break;
655676
}
656677
case "add-from-property": {
657-
const parsed = parseGsapScript(block.scriptText);
658-
const anim = parsed.animations.find((a) => a.id === body.animationId);
659-
if (!anim) return c.json({ error: "animation not found" }, 404);
660-
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
678+
const r = requireFromToAnimation(block.scriptText, body.animationId);
679+
if ("err" in r) return r.err;
661680
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
662-
fromProperties: { ...(anim.fromProperties ?? {}), [body.property]: body.defaultValue },
681+
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.defaultValue },
663682
});
664683
break;
665684
}
666685
case "remove-property": {
667-
const parsed = parseGsapScript(block.scriptText);
668-
const anim = parsed.animations.find((a) => a.id === body.animationId);
669-
if (!anim) return c.json({ error: "animation not found" }, 404);
670-
const filtered = { ...anim.properties };
686+
const r = requireAnimation(block.scriptText, body.animationId);
687+
if ("err" in r) return r.err;
688+
const filtered = { ...r.anim.properties };
671689
delete filtered[body.property];
672690
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
673691
properties: filtered,
674692
});
675693
break;
676694
}
677695
case "remove-from-property": {
678-
const parsed = parseGsapScript(block.scriptText);
679-
const anim = parsed.animations.find((a) => a.id === body.animationId);
680-
if (!anim) return c.json({ error: "animation not found" }, 404);
681-
if (anim.method !== "fromTo") return c.json({ error: "animation is not a fromTo" }, 400);
682-
const filtered = { ...(anim.fromProperties ?? {}) };
696+
const r = requireFromToAnimation(block.scriptText, body.animationId);
697+
if ("err" in r) return r.err;
698+
const filtered = { ...(r.anim.fromProperties ?? {}) };
683699
delete filtered[body.property];
684700
newScript = updateAnimationInScript(block.scriptText, body.animationId, {
685701
fromProperties: filtered,

packages/core/src/studio-api/routes/projects.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
1+
import { readFile } from "node:fs/promises";
2+
import { join } from "node:path";
13
import type { Hono } from "hono";
24
import type { StudioApiAdapter } from "../types.js";
35
import { walkDir } from "../helpers/safePath.js";
46

7+
const COMPOSITION_ID_RE = /data-composition-id\s*=/;
8+
9+
async function filterCompositionFiles(projectDir: string, files: string[]): Promise<string[]> {
10+
const htmlFiles = files.filter((f) => f.endsWith(".html"));
11+
const checks = await Promise.all(
12+
htmlFiles.map(async (f) => {
13+
try {
14+
const content = await readFile(join(projectDir, f), "utf-8");
15+
return COMPOSITION_ID_RE.test(content);
16+
} catch {
17+
return false;
18+
}
19+
}),
20+
);
21+
return htmlFiles.filter((_, i) => checks[i]);
22+
}
23+
524
export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): void {
625
// List all projects
726
api.get("/projects", async (c) => {
@@ -25,6 +44,7 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi
2544
const project = await adapter.resolveProject(c.req.param("id"));
2645
if (!project) return c.json({ error: "not found" }, 404);
2746
const files = walkDir(project.dir);
28-
return c.json({ id: project.id, dir: project.dir, title: project.title, files });
47+
const compositions = await filterCompositionFiles(project.dir, files);
48+
return c.json({ id: project.id, dir: project.dir, title: project.title, files, compositions });
2949
});
3050
}
-11.5 KB
Loading
-15.9 KB
Loading

0 commit comments

Comments
 (0)