fix(studio): gate Layout and Style panel sections on the element having a rendered box

Both panels showed Layout (X/Y/W/H/Rotation/Z-index) unconditionally —
no gate existed for it at all — and Style was gated only on
canEditStyles (a permission check), never on the element's tag. Neither
gate accounted for `<audio>`, which never paints a visual frame, so a
music track's inspector showed a full set of position/size/fill/shadow
controls with zero visual effect.

Add `layout`/`style` applicability to resolveEditingSections (core),
keyed on tag !== "audio", and gate both panels' Layout section and the
existing Style gate on it. Media/Motion/Grade/Text were already
correctly gated (verified via a research pass across both panels) and
are untouched.
This commit is contained in:
Vance Ingalls
2026-07-16 01:47:46 -07:00
parent 14dbfca5a7
commit 8a793ba2b2
4 changed files with 220 additions and 191 deletions
@@ -144,6 +144,18 @@ describe("resolveEditingAffordances — sections", () => {
expect(s).toMatchObject({ media: true, colorGrading: false });
});
it("audio: no layout or style — an audio element has no rendered box", () => {
const s = resolveEditingAffordances(baseFacts({ tag: "audio" })).sections;
expect(s).toMatchObject({ layout: false, style: false });
});
it("div/img/video: layout + style apply", () => {
for (const tag of ["div", "img", "video"]) {
const s = resolveEditingAffordances(baseFacts({ tag })).sections;
expect(s).toMatchObject({ layout: true, style: true });
}
});
it("img: media + colorGrading", () => {
const s = resolveEditingAffordances(baseFacts({ tag: "img" })).sections;
expect(s).toMatchObject({ media: true, colorGrading: true });
+13
View File
@@ -31,6 +31,13 @@ export interface EditingSectionApplicability {
colorGrading: boolean;
timing: boolean;
animation: boolean;
/** Position/size/rotation/stacking — meaningless on an element with no
* rendered box (e.g. `<audio>`, which never paints a visual frame). */
layout: boolean;
/** Fill/radius/stroke/shadow/blend-mode/clip — same "no rendered box" gate
* as `layout`, kept separate since a future tag could need one without
* the other. */
style: boolean;
}
export interface EditingAffordances {
@@ -193,12 +200,18 @@ function resolveCapabilities(facts: EditableElementFacts): DomEditCapabilities {
* without re-running the capability geometry parse.
*/
export function resolveEditingSections(facts: EditableElementFacts): EditingSectionApplicability {
// `<audio>` never paints a visual frame — position/size/rotation/stacking
// and fill/radius/stroke/shadow/etc. are all inert on it. Every other tag
// (div, img, video, svg, canvas, composition hosts) renders a real box.
const hasVisualBox = facts.tag !== "audio";
return {
text: facts.hasEditableText && !facts.isCompositionHost && !facts.isInsideLockedComposition,
media: facts.tag === "video" || facts.tag === "audio" || facts.tag === "img",
colorGrading: facts.tag === "video" || facts.tag === "img",
timing: facts.hasTimingStart || facts.animationCount > 0,
animation: facts.animationCount > 0,
layout: hasVisualBox,
style: hasVisualBox,
};
}
@@ -197,11 +197,11 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation;
const sourceLabel = element.id ? `#${element.id}` : (element.selector ?? "");
const showEditableSections = element.capabilities.canEditStyles;
// Capabilities are already resolved on the selection; recompute only sections,
// feeding the live GSAP tween count (arrives on the gsapAnimations prop, not the
// selection) so the Timing section shows for pure-GSAP elements with no data-start.
const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length));
const showEditableSections = element.capabilities.canEditStyles && sections.style;
const manualOffset = readStudioPathOffset(element.element);
const manualSize = readStudioBoxSize(element.element);
const resolvedWidth =
@@ -385,161 +385,163 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
/>
)}
<Section title="Layout" icon={<Move size={15} />}>
<div className={RESPONSIVE_GRID}>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="X"
value={formatPxMetricValue(displayX)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("x", next)}
/>
{sections.layout && (
<Section title="Layout" icon={<Move size={15} />}>
<div className={RESPONSIVE_GRID}>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="X"
value={formatPxMetricValue(displayX)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("x", next)}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
<KeyframeNavigation
property="x"
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "x", displayX)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("x"), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("x"))}
/>
)}
</div>
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
<KeyframeNavigation
property="x"
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "x", displayX)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("x"), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("x"))}
/>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="Y"
value={formatPxMetricValue(displayY)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("y", next)}
/>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="Y"
value={formatPxMetricValue(displayY)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("y", next)}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
<KeyframeNavigation
property="y"
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "y", displayY)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("y"), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("y"))}
/>
)}
</div>
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
<KeyframeNavigation
property="y"
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "y", displayY)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("y"), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("y"))}
/>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="W"
value={formatPxMetricValue(displayW)}
disabled={manualSizeEditingDisabled}
scrub
onCommit={(next) => commitManualSize("width", next)}
/>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="W"
value={formatPxMetricValue(displayW)}
disabled={manualSizeEditingDisabled}
scrub
onCommit={(next) => commitManualSize("width", next)}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
<KeyframeNavigation
property="width"
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "width", displayW)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("width"), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("width"))}
/>
)}
</div>
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
<KeyframeNavigation
property="width"
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "width", displayW)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("width"), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("width"))}
/>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="H"
value={formatPxMetricValue(displayH)}
disabled={manualSizeEditingDisabled}
scrub
onCommit={(next) => commitManualSize("height", next)}
/>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="H"
value={formatPxMetricValue(displayH)}
disabled={manualSizeEditingDisabled}
scrub
onCommit={(next) => commitManualSize("height", next)}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
<KeyframeNavigation
property="height"
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "height", displayH)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("height"), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("height"))}
/>
)}
</div>
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
<KeyframeNavigation
property="height"
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "height", displayH)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("height"), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("height"))}
/>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="R"
value={`${displayR}°`}
disabled={manualRotationEditingDisabled}
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
/>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="R"
value={`${displayR}°`}
disabled={manualRotationEditingDisabled}
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
<KeyframeNavigation
property="rotation"
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "rotation", displayR)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("rotation"), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))}
/>
)}
</div>
{STUDIO_KEYFRAMES_ENABLED && gsapAnimId && (
<KeyframeNavigation
property="rotation"
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "rotation", displayR)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("rotation"), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))}
/>
)}
</div>
</div>
<PropertyPanel3dTransform
gsapRuntimeValues={gsap3dValues}
gsapAnimId={gsapAnimId}
resolveAnimIdForProp={animIdForProp}
gsapKeyframes={navKeyframes}
currentPct={currentPct}
elStart={elStart}
elDuration={elDuration}
element={element}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
onLivePreviewProps={createGsapLivePreview(iframeRef)}
/>
<div className="mt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
Stacking
</div>
<MetricField
label="Z-index"
value={String(parseInt(styles["z-index"] || "auto", 10) || 0)}
scrub
onCommit={(next) => onSetStyle("z-index", next)}
<PropertyPanel3dTransform
gsapRuntimeValues={gsap3dValues}
gsapAnimId={gsapAnimId}
resolveAnimIdForProp={animIdForProp}
gsapKeyframes={navKeyframes}
currentPct={currentPct}
elStart={elStart}
elDuration={elDuration}
element={element}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
onLivePreviewProps={createGsapLivePreview(iframeRef)}
/>
</div>
</Section>
<div className="mt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
Stacking
</div>
<MetricField
label="Z-index"
value={String(parseInt(styles["z-index"] || "auto", 10) || 0)}
scrub
onCommit={(next) => onSetStyle("z-index", next)}
/>
</div>
</Section>
)}
{STUDIO_GSAP_PANEL_ENABLED &&
onUpdateGsapProperty &&
@@ -388,48 +388,50 @@ export function PropertyPanelFlat({
),
});
}
groups.push({
id: "layout",
title: "Layout",
// No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing
// (wheel/arrow keys only) — advertising "drag values to scrub" here lies.
summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`,
content: (
<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 })}
/>
),
});
if (sections.layout) {
groups.push({
id: "layout",
title: "Layout",
// No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing
// (wheel/arrow keys only) — advertising "drag values to scrub" here lies.
summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`,
content: (
<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 })}
/>
),
});
}
if (showMotionGroup) {
groups.push({
id: "motion",