feat(studio): wire the flat Layout group into the one-open accordion

Thread the Layout-group values through PropertyPanel -> PropertyPanelFlat
and add the third FlatGroup to the one-open/pin accordion (unconditional,
matching legacy Layout). Default-open Layout when neither Text nor Style
applies.

Fix the Flex double-render: the legacy StyleSections still renders its own
Flex Section, and the new flat Layout group renders its own LayoutFlexBlock.
Add an additive optional hideFlex prop to StyleSections and pass it on the
flat path so Flex renders exactly once (from the flat Layout group). Non-flat
callers omit it and are unchanged.

Extract the shared onLivePreviewProps closure into gsapLivePreview.ts (it was
duplicated inline in the legacy path) so PropertyPanel.tsx stays within the
600-LOC studio gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-09 11:52:16 -07:00
co-authored by Claude Sonnet 5
parent 58f2b1bbaa
commit 382989a06c
5 changed files with 229 additions and 24 deletions
@@ -114,6 +114,22 @@ function styleOnlyElement() {
};
}
// Flex fixture (Plan 3a Task 5): display:flex drives BOTH the legacy
// StyleSections Flex `Section` AND the new flat Layout group's
// LayoutFlexBlock. Used to prove Flex renders exactly once on the flat path.
// styles are read from computedStyles (PropertyPanel line ~113), so set it
// there.
function flexElement() {
return {
...baseElement(),
id: "flex-row",
selector: ".flex-row",
label: "Flex Row",
textFields: [],
computedStyles: { display: "flex" },
};
}
async function renderPanel(
flatEnabled: boolean,
elementOverride: ReturnType<typeof baseElement> = baseElement(),
@@ -266,3 +282,50 @@ describe("PropertyPanel — Style group (flag on)", () => {
RENDER_TIMEOUT_MS,
);
});
describe("PropertyPanel — Layout group (Plan 3a)", () => {
it(
"always renders the Layout group, and opening it closes whichever other group was open",
async () => {
const { host, root } = await renderPanel(true);
// Text group is open by default for the base text-editable fixture.
expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Text");
const layoutCollapsedRow = Array.from(
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
).find((el) => el.textContent?.includes("Layout"));
if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const openGroup = host.querySelector('[data-flat-group-open="true"]');
expect(openGroup?.textContent).toContain("Layout");
expect(openGroup?.textContent).toContain("X");
expect(openGroup?.textContent).not.toContain("Ask agent"); // sanity: not matching the footer
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
it(
"renders Flex exactly once on the flat path (flat Layout only, legacy suppressed)",
async () => {
const { host, root } = await renderPanel(true, flexElement());
const layoutCollapsedRow = Array.from(
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
).find((el) => el.textContent?.includes("Layout"));
if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
// The legacy StyleSections Flex `Section` (data-panel-section="flex") must
// NOT render on the flat path — the only two Flex renderers are the legacy
// Section and the flat LayoutFlexBlock, so its absence + the flat block's
// presence proves Flex renders exactly once (not twice, not zero).
expect(host.querySelector('[data-panel-section="flex"]')).toBeNull();
const openGroup = host.querySelector('[data-flat-group-open="true"]');
expect(openGroup?.textContent).toContain("Layout");
expect(openGroup?.textContent).toContain("Flex");
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
@@ -31,6 +31,7 @@ import {
STUDIO_KEYFRAMES_ENABLED,
} from "./manualEditingAvailability";
import { PropertyPanelFlat } from "./PropertyPanelFlat";
import { createGsapLivePreview } from "./gsapLivePreview";
import { usePlayerStore, liveTime } from "../../player";
import { TimingSection } from "./propertyPanelTimingSection";
import { type PropertyPanelProps } from "./propertyPanelHelpers";
@@ -279,6 +280,24 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
selectedElementId={selectedElementId}
clipboardCopied={clipboardCopied}
onCopyElementInfo={handleCopyElementInfo}
displayX={displayX}
displayY={displayY}
displayW={displayW}
displayH={displayH}
displayR={displayR}
manualOffsetEditingDisabled={manualOffsetEditingDisabled}
manualSizeEditingDisabled={manualSizeEditingDisabled}
manualRotationEditingDisabled={manualRotationEditingDisabled}
commitManualOffset={commitManualOffset}
commitManualSize={commitManualSize}
commitManualRotation={commitManualRotation}
gsapAnimId={gsapAnimId}
navKeyframes={navKeyframes}
currentPct={currentPct}
animIdForProp={animIdForProp}
gsapRuntimeValues={gsap3dValues}
elStart={elStart}
elDuration={elDuration}
/>
);
}
@@ -522,16 +541,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
onLivePreviewProps={(el, props) => {
const iframe = iframeRef.current;
const win = iframe?.contentWindow as
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
| null
| undefined;
const sel = el.id ? `#${el.id}` : el.selector;
const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
if (win?.gsap && node) win.gsap.set(node, props);
}}
onLivePreviewProps={createGsapLivePreview(iframeRef)}
/>
<div className="mt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
@@ -3,11 +3,14 @@ import { resolveEditingSections } from "@hyperframes/core/editing";
import type { DomEditSelection } from "./domEditing";
import { isTextEditableSelection } from "./domEditing";
import type { PropertyPanelProps } from "./propertyPanelHelpers";
import { formatPxMetricValue } from "./propertyPanelHelpers";
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { FlatGroup } from "./propertyPanelFlatPrimitives";
import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
import { createGsapLivePreview } from "./gsapLivePreview";
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
import { TimingSection } from "./propertyPanelTimingSection";
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
@@ -64,6 +67,29 @@ export function PropertyPanelFlat({
recordingState,
recordingDuration,
onToggleRecording,
displayX,
displayY,
displayW,
displayH,
displayR,
manualOffsetEditingDisabled,
manualSizeEditingDisabled,
manualRotationEditingDisabled,
commitManualOffset,
commitManualSize,
commitManualRotation,
gsapAnimId,
navKeyframes,
currentPct,
animIdForProp,
gsapRuntimeValues,
elStart,
elDuration,
onCommitAnimatedProperty,
onCommitAnimatedProperties,
onSeekToTime,
onRemoveKeyframe,
onConvertToKeyframes,
}: Pick<
PropertyPanelProps,
| "projectId"
@@ -91,18 +117,47 @@ export function PropertyPanelFlat({
| "recordingState"
| "recordingDuration"
| "onToggleRecording"
> & {
element: DomEditSelection;
styles: Record<string, string>;
sections: EditingSections;
sourceLabel: string;
gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null;
showEditableSections: boolean;
selectedElementHidden: boolean;
selectedElementId: string | null;
clipboardCopied: boolean;
onCopyElementInfo: () => void;
}) {
> &
// Layout-group values (Plan 3a Task 5). All are derived locals or handlers in
// PropertyPanel; compose their exact shapes from FlatLayoutSection's own props
// via Pick so a signature change there propagates here instead of drifting.
Pick<
Parameters<typeof FlatLayoutSection>[0],
| "displayX"
| "displayY"
| "displayW"
| "displayH"
| "displayR"
| "manualOffsetEditingDisabled"
| "manualSizeEditingDisabled"
| "manualRotationEditingDisabled"
| "commitManualOffset"
| "commitManualSize"
| "commitManualRotation"
| "gsapAnimId"
| "navKeyframes"
| "currentPct"
| "animIdForProp"
| "gsapRuntimeValues"
| "elStart"
| "elDuration"
| "onCommitAnimatedProperty"
| "onCommitAnimatedProperties"
| "onSeekToTime"
| "onRemoveKeyframe"
| "onConvertToKeyframes"
> & {
element: DomEditSelection;
styles: Record<string, string>;
sections: EditingSections;
sourceLabel: string;
gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null;
showEditableSections: boolean;
selectedElementHidden: boolean;
selectedElementId: string | null;
clipboardCopied: boolean;
onCopyElementInfo: () => void;
}) {
// Lazy initializer: pick whichever group actually renders for this element
// (Text if text-editable, else Style if style-editable, else none open) so a
// style-only element doesn't start with everything collapsed. Only runs on
@@ -110,7 +165,7 @@ export function PropertyPanelFlat({
// switching the selection re-mounts this component and re-derives the
// default instead of preserving stale state across unrelated elements.
const [openGroupId, setOpenGroupId] = useState<string>(() =>
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "",
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "layout",
);
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
@@ -122,6 +177,9 @@ export function PropertyPanelFlat({
setPinnedGroupIds((current) =>
current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId],
);
// Trivial percentage→time seek, derived here rather than threaded from
// PropertyPanel (keeps that file under its 600-LOC gate).
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
@@ -185,6 +243,50 @@ export function PropertyPanelFlat({
</FlatGroup>
)}
<FlatGroup
title="Layout"
isOpen={openGroupId === "layout" || pinnedGroupIds.includes("layout")}
isPinned={pinnedGroupIds.includes("layout")}
onToggleOpen={() => toggleOpen("layout")}
onTogglePin={() => togglePin("layout")}
accessory={<span className="text-[9px] text-panel-text-5">drag values to scrub</span>}
summary={`${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`}
>
<FlatLayoutSection
element={element}
styles={styles}
onSetStyle={onSetStyle}
disabled={!element.capabilities.canEditStyles}
displayX={displayX}
displayY={displayY}
displayW={displayW}
displayH={displayH}
displayR={displayR}
manualOffsetEditingDisabled={manualOffsetEditingDisabled}
manualSizeEditingDisabled={manualSizeEditingDisabled}
manualRotationEditingDisabled={manualRotationEditingDisabled}
commitManualOffset={commitManualOffset}
commitManualSize={commitManualSize}
commitManualRotation={commitManualRotation}
gsapAnimId={gsapAnimId}
navKeyframes={navKeyframes}
currentPct={currentPct}
seekFromKfPct={seekFromKfPct}
animIdForProp={animIdForProp}
resolveAnimIdForProp={animIdForProp}
gsapRuntimeValues={gsapRuntimeValues}
gsapKeyframes={navKeyframes}
elStart={elStart}
elDuration={elDuration}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
onLivePreviewProps={createGsapLivePreview(previewIframeRef ?? { current: null })}
/>
</FlatGroup>
{sections.timing && (
<TimingSection
element={element}
@@ -229,6 +331,9 @@ export function PropertyPanelFlat({
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
// Flex now lives in the flat Layout group (LayoutFlexBlock); suppress
// the legacy StyleSections Flex `Section` so it renders exactly once.
hideFlex
/>
)}
</div>
@@ -0,0 +1,22 @@
import type { DomEditSelection } from "./domEditingTypes";
/**
* Build the "live preview" callback the 3D-transform sub-view fires while a
* value is being dragged: apply a gsap.set() to the matching node inside the
* preview iframe so the edit is reflected immediately, before it's committed.
*
* Extracted so the identical closure exists once — shared by the legacy
* PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat).
*/
export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) {
return (el: DomEditSelection, props: Record<string, number>) => {
const iframe = iframeRef.current;
const win = iframe?.contentWindow as
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
| null
| undefined;
const sel = el.id ? `#${el.id}` : el.selector;
const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
if (win?.gsap && node) win.gsap.set(node, props);
};
}
@@ -47,6 +47,7 @@ export function StyleSections({
onSetStyle,
onImportAssets,
gsapBorderRadius,
hideFlex = false,
}: {
projectId: string;
element: DomEditSelection;
@@ -55,6 +56,10 @@ export function StyleSections({
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onImportAssets?: (files: FileList) => Promise<string[]>;
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
// When true, the Flex `Section` is suppressed. The flat inspector renders
// its own Flex controls inside the Layout group (LayoutFlexBlock), so the
// flat path passes this to avoid a double-render. Non-flat callers omit it.
hideFlex?: boolean;
}) {
const styleEditingDisabled = !element.capabilities.canEditStyles;
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
@@ -145,7 +150,7 @@ export function StyleSections({
return (
<>
{isFlex && (
{isFlex && !hideFlex && (
<Section title="Flex" icon={<Layers size={15} />} defaultCollapsed>
<div className="space-y-4">
<SegmentedControl