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).
This commit is contained in:
Miguel Ángel
2026-05-29 22:18:27 -04:00
committed by GitHub
parent 307e391d91
commit 1284213886
81 changed files with 1567 additions and 1017 deletions
@@ -8,23 +8,49 @@ import {
EASE_LABELS,
METHOD_LABELS,
METHOD_TOOLTIPS,
PERCENT_PROPS,
PROP_LABELS,
PROP_TOOLTIPS,
PROP_UNITS,
} from "./gsapAnimationConstants";
import { buildTweenSummary } from "./gsapAnimationHelpers";
import { EaseCurveSection } from "./EaseCurveSection";
const BOOLEAN_PROPS = new Set(["visibility"]);
const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
function isPercentProp(prop: string): boolean {
return PERCENT_PROPS.has(prop);
}
function displayValue(prop: string, val: number | string): string {
return isPercentProp(prop) ? String(Math.round(Number(val) * 100)) : String(val);
if (isPercentProp(prop)) return String(Math.round(Math.max(0, Math.min(1, Number(val))) * 100));
return String(val);
}
function adjustedValue(prop: string, raw: string): string {
return isPercentProp(prop) ? String(Number(raw) / 100) : raw;
if (isPercentProp(prop)) return String(Math.max(0, Math.min(1, Number(raw) / 100)));
return raw;
}
function RemoveButton({ onClick, title }: { onClick: () => void; title: string }) {
return (
<button
type="button"
onClick={onClick}
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
title={title}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
);
}
function PropertyRow({
@@ -40,6 +66,30 @@ function PropertyRow({
onRemove: () => void;
removeTitle: string;
}) {
if (BOOLEAN_PROPS.has(prop)) {
const isVisible = val === "visible" || val === 1;
return (
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
<span className="flex-1 text-[11px] font-medium text-neutral-500">
{PROP_LABELS[prop] ?? prop}
</span>
<button
type="button"
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
className={`flex-shrink-0 w-7 h-4 rounded-full transition-colors relative ${isVisible ? "bg-emerald-500/30" : "bg-neutral-700"}`}
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full transition-transform ${isVisible ? "bg-emerald-400 translate-x-3.5" : "bg-neutral-500 translate-x-0.5"}`}
/>
</button>
</div>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
);
}
return (
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
@@ -53,23 +103,7 @@ function PropertyRow({
onCommit={(raw) => onCommit(adjustedValue(prop, raw))}
/>
</div>
<button
type="button"
onClick={onRemove}
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
title={removeTitle}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
);
}
@@ -124,36 +158,6 @@ function AddPropertyTrigger({
);
}
// fallow-ignore-next-line complexity
function buildTweenSummary(animation: GsapAnimation): string {
const easeName = animation.ease ?? "none";
const ease = EASE_LABELS[easeName] ?? easeName;
const props = Object.entries(animation.properties);
const target = animation.targetSelector;
const dur = animation.duration ?? 0;
const pos = animation.position;
const propDescs = props.map(([p, v]) => {
const label = (PROP_LABELS[p] ?? p).toLowerCase();
const unit = PROP_UNITS[p] ?? "";
return `${label} to ${v}${unit}`;
});
const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
if (animation.method === "from")
return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
if (animation.method === "fromTo") {
const fromProps = Object.entries(animation.fromProperties ?? {});
const fromDescs = fromProps.map(([p, v]) => {
const label = (PROP_LABELS[p] ?? p).toLowerCase();
const unit = PROP_UNITS[p] ?? "";
return `${label} ${v}${unit}`;
});
const fromText = fromDescs.length > 0 ? fromDescs.join(", ") : "—";
return `Starting at ${pos}s, over ${dur}s, ${target} animates from [${fromText}] to [${propText}] using a ${ease.toLowerCase()} curve.`;
}
return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
}
function parseNumericOrString(raw: string): number | string {
const num = Number(raw);
return Number.isFinite(num) ? num : raw;
@@ -201,8 +205,11 @@ export const AnimationCard = memo(function AnimationCard({
[animation.properties],
);
const availableProps = useMemo(
() => SUPPORTED_PROPS.filter((p) => !usedProps.has(p)),
[usedProps],
() =>
SUPPORTED_PROPS.filter(
(p) => !usedProps.has(p) && (animation.method === "set" || !BOOLEAN_PROPS.has(p)),
),
[usedProps, animation.method],
);
const usedFromProps = useMemo(
@@ -210,7 +217,7 @@ export const AnimationCard = memo(function AnimationCard({
[animation.fromProperties],
);
const availableFromProps = useMemo(
() => SUPPORTED_PROPS.filter((p) => !usedFromProps.has(p)),
() => SUPPORTED_PROPS.filter((p) => !usedFromProps.has(p) && !BOOLEAN_PROPS.has(p)),
[usedFromProps],
);
@@ -118,11 +118,14 @@ export function EaseCurveSection({
{progress !== null ? "Playing…" : "Preview"}
</button>
</div>
<div className="overflow-hidden rounded pt-[72px] -mt-[72px]">
<div
className="overflow-hidden rounded pt-[72px] -mt-[72px]"
style={{ aspectRatio: `${w}/${h}` }}
>
<svg
ref={svgRef}
width="100%"
height={h}
height="100%"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
style={{ overflow: "visible" }}
@@ -135,6 +135,7 @@ function TimingSection({
/* PropertyPanel */
/* ------------------------------------------------------------------ */
// fallow-ignore-next-line complexity
export const PropertyPanel = memo(function PropertyPanel({
projectId,
projectDir,
@@ -229,6 +230,7 @@ export const PropertyPanel = memo(function PropertyPanel({
});
};
// fallow-ignore-next-line complexity
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null || parsed <= 0) return;
@@ -281,7 +283,7 @@ export const PropertyPanel = memo(function PropertyPanel({
className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-studio-accent/40 hover:text-studio-accent"
>
<MessageSquare size={15} />
<span>{copiedAgentPrompt ? "Prompt copied" : "Ask agent"}</span>
<span>{copiedAgentPrompt ? "Prompt copied" : "Copy prompt to AI agent"}</span>
</button>
</div>
</div>
@@ -95,3 +95,17 @@ export function buildElementAgentPrompt({
return lines.join("\n");
}
export function buildAgentContextPreview(
selection: DomEditSelection,
activeCompPath: string | null,
): string {
return [
`Composition: ${selection.compositionPath}`,
`Source: ${selection.sourceFile || activeCompPath || "index.html"}`,
`Selector: ${selection.selector ?? "(none)"} Tag: <${selection.tagName}>`,
selection.textContent ? `Text: ${selection.textContent}` : "",
]
.filter(Boolean)
.join("\n");
}
@@ -153,31 +153,49 @@ export function resolveVisualDomEditSelectionTarget(
elementsFromPoint: Iterable<Element | null | undefined>,
options: Pick<DomEditContextOptions, "activeCompositionPath">,
): HTMLElement | null {
const candidates: HTMLElement[] = [];
const candidates = resolveAllVisualDomEditTargets(elementsFromPoint, options);
return candidates[0] ?? null;
}
/**
* Returns all independently-selectable elements at the given point, in paint
* order (topmost first). Used for click-cycling through stacked layers.
*
* Each entry in the returned array is an independent "layer" — an element
* that is not an ancestor of an earlier entry. This gives one result per
* z-stacked element rather than one per DOM node.
*/
export function resolveAllVisualDomEditTargets(
elementsFromPoint: Iterable<Element | null | undefined>,
options: Pick<DomEditContextOptions, "activeCompositionPath">,
): HTMLElement[] {
const raw: HTMLElement[] = [];
for (const entry of elementsFromPoint) {
if (!isHtmlElement(entry)) continue;
if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
candidates.push(entry);
raw.push(entry);
}
}
if (candidates.length === 0) return null;
if (raw.length === 0) return [];
// candidates are in visual stacking order (topmost first, from elementsFromPoint).
// Start with the topmost and only replace with a descendant that is more
// specific within the same visual subtree. Never jump to an unrelated
// element that happens to be painted behind the current pick.
let best = candidates[0];
for (let i = 1; i < candidates.length; i++) {
const candidate = candidates[i];
if (best.contains(candidate)) {
best = candidate;
// First pass: for each contiguous ancestor-descendant run, keep only the
// deepest (most specific) element, matching the original single-pick logic.
const layers: HTMLElement[] = [];
let best = raw[0];
for (let i = 1; i < raw.length; i++) {
const el = raw[i];
if (best.contains(el)) {
best = el; // go deeper in this subtree
} else {
layers.push(best);
best = el;
}
}
layers.push(best);
return best;
return layers;
}
// ─── Raster detection ────────────────────────────────────────────────────────
@@ -248,6 +266,7 @@ export function findElementForSelection(
return matches[0] ?? null;
}
// fallow-ignore-next-line complexity
export function findElementForTimelineElement(
doc: Document,
element: TimelineElementDomTarget,
@@ -4,7 +4,7 @@ export const METHOD_LABELS: Record<string, string> = {
set: "Set",
to: "Animate",
from: "Animate In",
fromTo: "Animate",
fromTo: "From → To",
};
export const METHOD_TOOLTIPS: Record<string, string> = {
@@ -121,6 +121,8 @@ export function parseCustomEaseFromString(ease: string): {
return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] };
}
export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
export const ADD_METHODS = ["to", "from", "fromTo", "set"] as const;
export const ADD_METHOD_LABELS: Record<string, string> = {
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { buildTweenSummary } from "./gsapAnimationHelpers";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
return {
id: "a1",
method: "to",
targetSelector: "#box",
properties: {},
position: 0,
duration: 1,
ease: "power2.out",
...overrides,
} as GsapAnimation;
}
describe("buildTweenSummary", () => {
it("describes a to tween", () => {
const s = buildTweenSummary(anim({ properties: { opacity: 1, x: 100 } }));
expect(s).toContain("#box");
expect(s).toContain("opacity");
expect(s).toContain("move x");
});
it("describes a from tween", () => {
const s = buildTweenSummary(anim({ method: "from", properties: { opacity: 0 } }));
expect(s).toContain("enters from");
expect(s).toContain("opacity");
});
it("describes a set tween", () => {
const s = buildTweenSummary(anim({ method: "set", properties: { opacity: 0 } }));
expect(s).toMatch(/^At 0s, instantly set/);
expect(s).toContain("opacity");
});
it("describes a fromTo tween with both from and to sections", () => {
const s = buildTweenSummary(
anim({
method: "fromTo",
fromProperties: { opacity: 0, x: -50 },
properties: { opacity: 1, x: 0 },
position: 0.5,
duration: 1.5,
ease: "expo.out",
}),
);
expect(s).toContain("animates from");
expect(s).toContain("[opacity 0%");
expect(s).toContain("move x -50px");
expect(s).toContain("opacity to 100%");
expect(s).toContain("very snappy stop");
});
it("handles fromTo with empty fromProperties", () => {
const s = buildTweenSummary(
anim({ method: "fromTo", fromProperties: {}, properties: { scale: 2 } }),
);
expect(s).toContain("from [—]");
});
it("handles no properties", () => {
const s = buildTweenSummary(anim({ properties: {} }));
expect(s).toContain("no properties yet");
});
});
@@ -0,0 +1,36 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { EASE_LABELS, PERCENT_PROPS, PROP_LABELS, PROP_UNITS } from "./gsapAnimationConstants";
function formatPropValue(prop: string, v: number | string): string {
const unit = PROP_UNITS[prop] ?? "";
if (PERCENT_PROPS.has(prop)) return `${Math.round(Number(v) * 100)}${unit}`;
return `${v}${unit}`;
}
// fallow-ignore-next-line complexity
export function buildTweenSummary(animation: GsapAnimation): string {
const easeName = animation.ease ?? "none";
const ease = EASE_LABELS[easeName] ?? easeName;
const props = Object.entries(animation.properties);
const target = animation.targetSelector;
const dur = animation.duration ?? 0;
const pos = animation.position;
const propDescs = props.map(([p, v]) => {
const label = (PROP_LABELS[p] ?? p).toLowerCase();
return `${label} to ${formatPropValue(p, v)}`;
});
const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
if (animation.method === "from")
return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
if (animation.method === "fromTo") {
const fromProps = Object.entries(animation.fromProperties ?? {});
const fromDescs = fromProps.map(([p, v]) => {
const label = (PROP_LABELS[p] ?? p).toLowerCase();
return `${label} ${formatPropValue(p, v)}`;
});
const fromText = fromDescs.length > 0 ? fromDescs.join(", ") : "—";
return `Starting at ${pos}s, over ${dur}s, ${target} animates from [${fromText}] to [${propText}] using a ${ease.toLowerCase()} curve.`;
}
return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
}
@@ -29,7 +29,7 @@ function CommitField({
const el = inputRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
if (disabled) return;
if (disabled || document.activeElement !== el) return;
const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
if (delta === 0) return;
const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);