From b57b31beb93026c6d9cb380b71d12608cc528796 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 22:44:21 -0700 Subject: [PATCH] feat(studio): render the flat Ledger inspector shell behind STUDIO_FLAT_INSPECTOR_ENABLED Co-Authored-By: Claude Sonnet 5 --- .../components/editor/PropertyPanel.test.tsx | 142 ++++++++++++ .../src/components/editor/PropertyPanel.tsx | 197 ++++++++--------- .../components/editor/PropertyPanelFlat.tsx | 209 ++++++++++++++++++ .../components/editor/propertyPanelHelpers.ts | 53 +++++ .../editor/propertyPanelSections.tsx | 2 +- 5 files changed, 503 insertions(+), 100 deletions(-) create mode 100644 packages/studio/src/components/editor/PropertyPanel.test.tsx create mode 100644 packages/studio/src/components/editor/PropertyPanelFlat.tsx diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx new file mode 100644 index 000000000..6b2ac355e --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -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( + "../../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( + "./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(); + }); + 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( + '[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( + '[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, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 53009b45b..e0c544eff 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -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 ; + return ( + + ); } 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 ( + + ); + } + return (
diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx new file mode 100644 index 000000000..e20b2c729 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -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; + +/** + * 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 3–4). 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; + 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("text"); + const [pinnedGroupIds, setPinnedGroupIds] = useState([]); + + const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other"; + + return ( +
+
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 0032c7ca3..993ea77c0 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -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, +): 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"); +} diff --git a/packages/studio/src/components/editor/propertyPanelSections.tsx b/packages/studio/src/components/editor/propertyPanelSections.tsx index 1a35eb600..303930526 100644 --- a/packages/studio/src/components/editor/propertyPanelSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelSections.tsx @@ -11,7 +11,7 @@ import { FontFamilyField } from "./propertyPanelFont"; /* 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)}…`;