feat(studio): render the flat Ledger inspector shell behind STUDIO_FLAT_INSPECTOR_ENABLED

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-14 00:59:06 -07:00
co-authored by Claude Sonnet 5
parent 82f3f340aa
commit e0066834b7
5 changed files with 503 additions and 100 deletions
@@ -0,0 +1,142 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PropertyPanelProps } from "./propertyPanelHelpers";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// PropertyPanel calls useStudioShellContext() unconditionally; supply the one
// field it reads (showToast) so the component can mount without the full shell.
vi.mock("../../contexts/StudioContext", async () => {
const actual = await vi.importActual<typeof import("../../contexts/StudioContext")>(
"../../contexts/StudioContext",
);
return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) };
});
afterEach(() => {
document.body.innerHTML = "";
vi.doUnmock("./manualEditingAvailability");
vi.resetModules();
});
function baseElement() {
return {
element: document.createElement("div"),
id: "mono-label",
selector: ".mono-label",
label: "Mono Label",
tagName: "div",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: -24, width: 257, height: 29 },
textContent: "PACKETS / FRAME",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [
{
key: "field-0",
label: "Text",
value: "PACKETS / FRAME",
tagName: "div",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "self",
},
],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
};
}
async function renderPanel(flatEnabled: boolean) {
vi.resetModules();
vi.doMock("./manualEditingAvailability", async () => {
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
"./manualEditingAvailability",
);
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: flatEnabled };
});
const { PropertyPanel } = await import("./PropertyPanel");
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
// Only the props the render path touches are supplied; the rest are unused at
// mount (handlers fire on interaction), so cast a minimal object to the full
// props shape rather than stubbing all ~15 required fields.
const props = {
element: baseElement(),
onSetStyle: vi.fn(),
onSetText: vi.fn(),
} as unknown as PropertyPanelProps;
act(() => {
root.render(<PropertyPanel {...props} />);
});
return { host, root };
}
// renderPanel resetModules()+dynamic-imports PropertyPanel (needed for a fresh
// flag read); transforming the full section graph uncached can exceed the 5s
// default under heavy parallel full-suite load, so give these a wider margin.
const RENDER_TIMEOUT_MS = 20_000;
describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED off", () => {
it(
"renders the legacy header, not the flat header",
async () => {
const { host, root } = await renderPanel(false);
expect(host.querySelector('[data-flat-header-icon="true"]')).toBeNull();
expect(host.textContent).toContain("Mono Label");
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
it(
"renders the flat header, the Text group open by default, and the flat footer",
async () => {
const { host, root } = await renderPanel(true);
expect(host.querySelector('[data-flat-header-icon="true"]')).not.toBeNull();
expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
expect(host.textContent).toContain("Ask agent about this element");
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
it(
"collapses the Text group on caret click and can reopen it",
async () => {
const { host, root } = await renderPanel(true);
const collapseButton = host.querySelector<HTMLButtonElement>(
'[data-flat-group-open="true"] button[title="Collapse"]',
);
act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
const collapsedRow = host.querySelector<HTMLButtonElement>(
'[data-flat-group-collapsed="true"]',
);
expect(collapsedRow).not.toBeNull();
act(() => collapsedRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
@@ -5,6 +5,7 @@ import { InspectorHeaderActions } from "./InspectorHeaderActions";
import { useStudioShellContext } from "../../contexts/StudioContext";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import {
buildElementInfoText,
EMPTY_STYLES,
formatPxMetricValue,
parsePxMetricValue,
@@ -24,7 +25,12 @@ import { TextSection, StyleSections } from "./propertyPanelSections";
import { GsapAnimationSection } from "./GsapAnimationSection";
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
import { KeyframeNavigation } from "./KeyframeNavigation";
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
import {
STUDIO_FLAT_INSPECTOR_ENABLED,
STUDIO_GSAP_PANEL_ENABLED,
STUDIO_KEYFRAMES_ENABLED,
} from "./manualEditingAvailability";
import { PropertyPanelFlat } from "./PropertyPanelFlat";
import { usePlayerStore, liveTime } from "../../player";
import { TimingSection } from "./propertyPanelTimingSection";
import { type PropertyPanelProps } from "./propertyPanelHelpers";
@@ -46,61 +52,64 @@ export {
} from "./propertyPanelHelpers";
// fallow-ignore-next-line complexity
export const PropertyPanel = memo(function PropertyPanel({
projectId,
projectDir,
assets,
element,
multiSelectCount = 0,
copiedAgentPrompt: _copiedAgentPrompt,
onClearSelection,
onUngroup,
onSetStyle,
onSetAttribute,
onSetAttributeLive,
onApplyColorGradingScope,
onSetHtmlAttribute,
onRemoveBackground,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
onSetText,
onSetTextFieldStyle,
onAddTextField,
onRemoveTextField,
onAskAgent: _onAskAgent,
onToggleElementHidden,
onImportAssets,
fontAssets = [],
onImportFonts,
previewIframeRef,
gsapAnimations = [],
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
onUpdateGsapProperty,
onUpdateGsapMeta,
onDeleteGsapAnimation,
onAddGsapProperty,
onRemoveGsapProperty,
onUpdateGsapFromProperty,
onAddGsapFromProperty,
onRemoveGsapFromProperty,
onAddGsapAnimation,
onSetArcPath,
onUpdateArcSegment,
onUnroll,
onUpdateKeyframeEase,
onSetAllKeyframeEases,
onAddKeyframe,
onRemoveKeyframe,
onConvertToKeyframes,
onCommitAnimatedProperty,
onCommitAnimatedProperties,
onSeekToTime,
recordingState,
recordingDuration,
onToggleRecording,
}: PropertyPanelProps) {
export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelProps) {
const {
projectId,
projectDir,
assets,
element,
multiSelectCount = 0,
multiSelectedElements,
onGroupSelection,
onHideAllSelected,
copiedAgentPrompt: _copiedAgentPrompt,
onClearSelection,
onUngroup,
onSetStyle,
onSetAttribute,
onSetAttributeLive,
onApplyColorGradingScope,
onSetHtmlAttribute,
onRemoveBackground,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
onSetText,
onSetTextFieldStyle,
onAddTextField,
onRemoveTextField,
onToggleElementHidden,
onImportAssets,
fontAssets = [],
onImportFonts,
previewIframeRef,
gsapAnimations = [],
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
onUpdateGsapProperty,
onUpdateGsapMeta,
onDeleteGsapAnimation,
onAddGsapProperty,
onRemoveGsapProperty,
onUpdateGsapFromProperty,
onAddGsapFromProperty,
onRemoveGsapFromProperty,
onAddGsapAnimation,
onSetArcPath,
onUpdateArcSegment,
onUnroll,
onUpdateKeyframeEase,
onSetAllKeyframeEases,
onAddKeyframe,
onRemoveKeyframe,
onConvertToKeyframes,
onCommitAnimatedProperty,
onCommitAnimatedProperties,
onSeekToTime,
recordingState,
recordingDuration,
onToggleRecording,
} = props;
const styles = element?.computedStyles ?? EMPTY_STYLES;
const { showToast } = useStudioShellContext();
const [clipboardCopied, setClipboardCopied] = useState(false);
@@ -170,13 +179,22 @@ export const PropertyPanel = memo(function PropertyPanel({
};
if (!element) {
return <PropertyPanelEmptyState multiSelectCount={multiSelectCount} />;
return (
<PropertyPanelEmptyState
flat={STUDIO_FLAT_INSPECTOR_ENABLED}
multiSelectCount={multiSelectCount}
multiSelectedElements={multiSelectedElements}
onGroupSelection={onGroupSelection}
onHideAllSelected={onHideAllSelected}
onClearSelection={onClearSelection}
/>
);
}
const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation;
const sourceLabel = element.id ? `#${element.id}` : element.selector;
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
@@ -234,48 +252,8 @@ export const PropertyPanel = memo(function PropertyPanel({
const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
// fallow-ignore-next-line complexity
const handleCopyElementInfo = () => {
const file = element.sourceFile ?? "index.html";
let lineNum: number | null = null;
try {
const src = previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
if (src && element.id) {
const idx = src.indexOf(`id="${element.id}"`);
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
}
if (!lineNum && element.selector) {
const tag = element.tagName.toLowerCase();
const cls = element.selector.startsWith(".")
? element.selector.slice(1).split(".")[0]
: null;
const search = cls ? `class="${cls}` : `<${tag}`;
const idx = src.indexOf(search);
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
}
} catch {}
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
const lines = [
`Element: ${element.label} (${sourceLabel})`,
`File: ${fileLoc}`,
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
`Tag: <${element.tagName}>`,
];
if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") {
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
}
if (gsapAnimations.length > 0) {
const anim = gsapAnimations[0];
lines.push(
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
);
const props = Object.entries(anim.properties)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
if (props) lines.push(`Properties: ${props}`);
}
const text = lines.join("\n");
const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef);
void navigator.clipboard.writeText(text);
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
setClipboardCopied(true);
@@ -283,6 +261,27 @@ export const PropertyPanel = memo(function PropertyPanel({
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
};
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
// Forward the raw props (handlers, ids, assets, recording, fonts, etc.) and
// the values the legacy path already computed above (so they aren't derived
// twice). PropertyPanelFlat owns the one-open/pin group state.
return (
<PropertyPanelFlat
{...props}
element={element}
styles={styles}
sections={sections}
sourceLabel={sourceLabel}
gsapBorderRadius={gsapBorderRadius}
showEditableSections={showEditableSections}
selectedElementHidden={selectedElementHidden}
selectedElementId={selectedElementId}
clipboardCopied={clipboardCopied}
onCopyElementInfo={handleCopyElementInfo}
/>
);
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<div className="px-4 py-3">
@@ -0,0 +1,209 @@
import { useState } from "react";
import { resolveEditingSections } from "@hyperframes/core/editing";
import type { DomEditSelection } from "./domEditing";
import type { PropertyPanelProps } from "./propertyPanelHelpers";
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { FlatGroup } from "./propertyPanelFlatPrimitives";
import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
import { TimingSection } from "./propertyPanelTimingSection";
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
import { MediaSection } from "./propertyPanelMediaSection";
type EditingSections = ReturnType<typeof resolveEditingSections>;
/**
* The flat "Ledger" inspector shell (design_handoff_studio_inspector).
*
* Extracted from PropertyPanel so that file stays under the 600-LOC gate
* (same one-directional-import precedent as FlatTextSection). Rendered only
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open/pin group state.
*
* Intentionally omits the Layout `Section` and `GsapAnimationSection` (Motion)
* — flattening those is Layout/Motion plan territory (plans 34). A text
* element with the flag on will not show Layout/Motion controls; that
* regression is scoped and acceptable for an unreleased, flag-gated feature.
*/
// fallow-ignore-next-line complexity
export function PropertyPanelFlat({
element,
styles,
sections,
sourceLabel,
gsapAnimations = [],
gsapBorderRadius,
fontAssets = [],
showEditableSections,
selectedElementHidden,
selectedElementId,
clipboardCopied,
onCopyElementInfo,
projectId,
projectDir,
assets,
previewIframeRef,
onClearSelection,
onUngroup,
onSetStyle,
onSetAttribute,
onSetAttributeLive,
onApplyColorGradingScope,
onSetHtmlAttribute,
onRemoveBackground,
onSetText,
onSetTextFieldStyle,
onAddTextField,
onRemoveTextField,
onAskAgent,
onToggleElementHidden,
onImportAssets,
onImportFonts,
recordingState,
recordingDuration,
onToggleRecording,
}: Pick<
PropertyPanelProps,
| "projectId"
| "projectDir"
| "assets"
| "previewIframeRef"
| "onClearSelection"
| "onUngroup"
| "onSetStyle"
| "onSetAttribute"
| "onSetAttributeLive"
| "onApplyColorGradingScope"
| "onSetHtmlAttribute"
| "onRemoveBackground"
| "onSetText"
| "onSetTextFieldStyle"
| "onAddTextField"
| "onRemoveTextField"
| "onAskAgent"
| "onToggleElementHidden"
| "onImportAssets"
| "onImportFonts"
| "fontAssets"
| "gsapAnimations"
| "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;
}) {
const [openGroupId, setOpenGroupId] = useState<string>("text");
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<PropertyPanelFlatHeader
name={element.label}
meta={`${sourceLabel} · ${element.tagName}`}
elementKind={elementKind}
hidden={selectedElementHidden}
onToggleHidden={
selectedElementId && onToggleElementHidden
? () => void onToggleElementHidden(selectedElementId, !selectedElementHidden)
: undefined
}
copied={clipboardCopied}
onCopy={onCopyElementInfo}
onClear={onClearSelection}
onUngroup={onUngroup}
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
/>
<div className="flex-1 overflow-y-auto">
<FlatGroup
title="Text"
isOpen={openGroupId === "text" || pinnedGroupIds.includes("text")}
isPinned={pinnedGroupIds.includes("text")}
onToggleOpen={() => setOpenGroupId((current) => (current === "text" ? "" : "text"))}
onTogglePin={() =>
setPinnedGroupIds((current) =>
current.includes("text")
? current.filter((id) => id !== "text")
: [...current, "text"],
)
}
summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")}
>
<FlatTextSection
element={element}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onAddTextField={onAddTextField}
onRemoveTextField={onRemoveTextField}
/>
</FlatGroup>
{sections.timing && (
<TimingSection
element={element}
animations={gsapAnimations}
onSetAttribute={onSetAttribute}
/>
)}
{sections.colorGrading && (
<ColorGradingSection
key={[
element.id ?? "",
element.hfId ?? "",
element.selector ?? "",
String(element.selectorIndex ?? ""),
].join("|")}
projectId={projectId}
element={element}
assets={assets}
previewIframeRef={previewIframeRef}
onImportAssets={onImportAssets}
onSetAttributeLive={onSetAttributeLive}
onApplyScope={onApplyColorGradingScope}
/>
)}
{sections.media && (
<MediaSection
projectDir={projectDir}
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>
)}
{showEditableSections && (
<StyleSections
projectId={projectId}
element={element}
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
/>
)}
</div>
<PropertyPanelFlatFooter
onAskAgent={onAskAgent}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
</div>
);
}
@@ -505,3 +505,56 @@ export function readGsapBorderRadiusForPanel(
return null;
}
}
/**
* Builds the multi-line "element info" text copied to the clipboard for an AI
* agent. Shared by both the legacy and flat inspector headers (the flat split
* needs the same string), so it lives here rather than as a PropertyPanel
* closure. Pure — the caller owns the clipboard write, toast, and copied state.
*/
// fallow-ignore-next-line complexity
export function buildElementInfoText(
element: DomEditSelection,
sourceLabel: string,
gsapAnimations: GsapAnimation[],
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>,
): string {
const file = element.sourceFile ?? "index.html";
let lineNum: number | null = null;
try {
const src = previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
if (src && element.id) {
const idx = src.indexOf(`id="${element.id}"`);
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
}
if (!lineNum && element.selector) {
const tag = element.tagName.toLowerCase();
const cls = element.selector.startsWith(".") ? element.selector.slice(1).split(".")[0] : null;
const search = cls ? `class="${cls}` : `<${tag}`;
const idx = src.indexOf(search);
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
}
} catch {}
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
const lines = [
`Element: ${element.label} (${sourceLabel})`,
`File: ${fileLoc}`,
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
`Tag: <${element.tagName}>`,
];
if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") {
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
}
if (gsapAnimations.length > 0) {
const anim = gsapAnimations[0];
lines.push(
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
);
const props = Object.entries(anim.properties)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
if (props) lines.push(`Properties: ${props}`);
}
return lines.join("\n");
}
@@ -12,7 +12,7 @@ import { PromotableControl } from "./PromotableControl";
/* Text helpers (used only by text section components) */
/* ------------------------------------------------------------------ */
function formatTextFieldPreview(value: string): string {
export function formatTextFieldPreview(value: string): string {
const collapsed = value.trim().replace(/\s+/g, " ");
if (collapsed.length <= 56) return collapsed;
return `${collapsed.slice(0, 55)}`;