diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 32a6d5bc85..1447133942 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -260,6 +260,18 @@ "file": "packages/core/src/figma/manifest.ts", "exports": ["mediaDir", "typeDirPath", "isFigmaManifestRecord"], }, + // STUDIO_FLAT_INSPECTOR_ENABLED: exported for use by downstream studio + // inspector redesign tasks; consumed by components in later PRs. + { + "file": "packages/studio/src/components/editor/manualEditingAvailability.ts", + "exports": ["STUDIO_FLAT_INSPECTOR_ENABLED"], + }, + // TextAreaField: newly exported for FlatTextSection (flat inspector + // redesign, Task 8), which lands in a later commit on this branch. + { + "file": "packages/studio/src/components/editor/propertyPanelSections.tsx", + "exports": ["TextAreaField"], + }, ], "ignoreDependencies": [ // Runtime/dynamic deps not visible to static analysis: tsup `external`, diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index c65fd3d0c4..c8f0bf8282 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -109,6 +109,7 @@ export function StudioRightPanel({ copiedAgentPrompt, clearDomSelection, handleUngroupSelection, + handleGroupSelection, handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, @@ -342,6 +343,10 @@ export function StudioRightPanel({ [projectId, refreshFileTree, showToast], ); + const handleHideAllSelected = () => + domEditGroupSelections + .map((el) => el.id ?? el.selector) + .forEach((key) => key && void onToggleElementHidden?.(key, true)); const propertyPanel = ( 1 ? null : domEditSelection} @@ -359,6 +364,9 @@ export function StudioRightPanel({ assets={assets} element={domEditGroupSelections.length > 1 ? null : domEditSelection} multiSelectCount={domEditGroupSelections.length} + multiSelectedElements={domEditGroupSelections} + onGroupSelection={handleGroupSelection} + onHideAllSelected={handleHideAllSelected} copiedAgentPrompt={copiedAgentPrompt} onClearSelection={clearDomSelection} onToggleElementHidden={onToggleElementHidden} 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 0000000000..5c3845e591 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -0,0 +1,211 @@ +// @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, + }, + }; +} + +// Bug 1 fixture: no text fields at all, so isTextEditableSelection(element) is +// false — the Text FlatGroup must not render (not even empty/collapsed). +function nonTextElement() { + return { + ...baseElement(), + id: "image-clip", + selector: "#image-clip", + label: "Image Clip", + tagName: "img", + textContent: "", + textFields: [], + }; +} + +// Bug 2 fixture: 2+ text fields, which routes FlatTextSection to the legacy +// multi-field fallback — must not double-render the "Text" +// heading (FlatGroup's own heading + TextSection's internal Section heading). +function multiFieldTextElement() { + const base = baseElement(); + return { + ...base, + textFields: [ + base.textFields[0], + { + key: "field-1", + label: "Text", + value: "SECOND FIELD", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ], + }; +} + +async function renderPanel( + flatEnabled: boolean, + elementOverride: ReturnType = baseElement(), +) { + 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: elementOverride, + assets: [], + onSetStyle: vi.fn(), + onSetText: vi.fn(), + onSetAttributeLive: 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, + ); + + it( + "renders no Text group at all for a non-text element (bug 1)", + async () => { + const { host, root } = await renderPanel(true, nonTextElement()); + expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull(); + expect(host.querySelector('[data-flat-group-collapsed="true"]')).toBeNull(); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "renders exactly one Text heading for a multi-field text element (bug 2)", + async () => { + const { host, root } = await renderPanel(true, multiFieldTextElement()); + // The FlatGroup's own "Text" heading is the only one that should exist — + // the legacy TextSection's internal Section heading (data-panel-section + // ="text") must be suppressed when it's used as the flat fallback. + expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull(); + expect(host.querySelector('[data-panel-section="text"]')).toBeNull(); + // Content from the legacy multi-field fallback must still render. + expect(host.textContent).toContain("Text layers"); + 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 53009b45b3..e0c544effd 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/PropertyPanelEmptyState.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx new file mode 100644 index 0000000000..397fb161cf --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState"; +import type { DomEditSelection } from "./domEditingTypes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderInto(node: React.ReactElement) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { host, root }; +} + +describe("PropertyPanelEmptyState — flat empty", () => { + it("shows the cursor glyph, headline, and the two shortcut rows", () => { + const { host, root } = renderInto(); + expect(host.textContent).toContain("Nothing selected"); + expect(host.textContent).toContain("Record a gesture"); + expect(host.textContent).toContain("Describe a change to the agent"); + act(() => root.unmount()); + }); +}); + +describe("PropertyPanelEmptyState — flat multi-select", () => { + const elements = [ + { id: "mono-label", selector: ".mono-label", label: "Mono Label", tagName: "div" }, + { id: null, selector: "#s2-chart", label: "S2 Chart", tagName: "div" }, + ] as unknown as DomEditSelection[]; + + it("lists each selected element and wires group/hide-all/clear actions", () => { + const onGroupSelection = vi.fn(); + const onHideAllSelected = vi.fn(); + const onClearSelection = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("2 elements selected"); + expect(host.textContent).toContain("Mono Label"); + expect(host.textContent).toContain("S2 Chart"); + + const group = host.querySelector('[data-flat-multiselect-group="true"]'); + act(() => group?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onGroupSelection).toHaveBeenCalledTimes(1); + + const hideAll = host.querySelector( + '[data-flat-multiselect-hide-all="true"]', + ); + act(() => hideAll?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onHideAllSelected).toHaveBeenCalledTimes(1); + + const clear = host.querySelector('[data-flat-multiselect-clear="true"]'); + act(() => clear?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onClearSelection).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx index 7070a9e366..4a30f867e7 100644 --- a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx @@ -1,6 +1,184 @@ import { Eye, Layers } from "../../icons/SystemIcons"; +import type { DomEditSelection } from "./domEditingTypes"; + +function FlatEmptyState() { + return ( +
+ + + + + +
Nothing selected
+
+ Click any element on the canvas to edit it, or drag to select several. +
+
+ + + + Record a gesture + + + R + + + + + + Describe a change to the agent + + + ⌘K + + +
+
+ ); +} + +function elementKindGlyph(element: DomEditSelection): { glyph: string; className: string } { + if (element.tagName === "video" || element.tagName === "audio" || element.tagName === "img") { + return { glyph: "◆", className: "bg-panel-media/10 text-panel-media" }; + } + if (element.textFields?.length > 0) { + return { glyph: "T", className: "bg-panel-accent/10 text-panel-accent" }; + } + return { glyph: "▦", className: "bg-panel-container/10 text-panel-container" }; +} + +function FlatMultiSelectState({ + multiSelectCount, + multiSelectedElements = [], + onGroupSelection, + onHideAllSelected, + onClearSelection, +}: { + multiSelectCount: number; + multiSelectedElements?: DomEditSelection[]; + onGroupSelection?: () => void; + onHideAllSelected?: () => void; + onClearSelection?: () => void; +}) { + return ( +
+
+ + + +
+
+ {multiSelectCount} elements selected +
+
+ shift-click to add or remove +
+
+ +
+
+ {multiSelectedElements.map((element) => { + const { glyph, className } = elementKindGlyph(element); + return ( + + + {glyph} + + + {element.label} + + + {element.id ? `#${element.id}` : element.selector} + + + ); + })} +
+
+ + +
+ + Select a single element to edit its properties + +
+ ); +} + +export function PropertyPanelEmptyState({ + multiSelectCount, + flat, + multiSelectedElements, + onGroupSelection, + onHideAllSelected, + onClearSelection, +}: { + multiSelectCount: number; + flat?: boolean; + multiSelectedElements?: DomEditSelection[]; + onGroupSelection?: () => void; + onHideAllSelected?: () => void; + onClearSelection?: () => void; +}) { + if (flat) { + return multiSelectCount > 1 ? ( + + ) : ( + + ); + } -export function PropertyPanelEmptyState({ multiSelectCount }: { multiSelectCount: number }) { return (
diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx new file mode 100644 index 0000000000..a6dfca7182 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -0,0 +1,217 @@ +import { useState } from "react"; +import { resolveEditingSections } from "@hyperframes/core/editing"; +import type { DomEditSelection } from "./domEditing"; +import { isTextEditableSelection } 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; +}) { + // Defaulting to "text" is harmless for a non-text element even though the + // Text FlatGroup won't render (nothing else reads openGroupId yet) — this + // only matters once a second FlatGroup exists (Plan 2+), at which point a + // non-text element should default-open that group instead. + const [openGroupId, setOpenGroupId] = useState("text"); + const [pinnedGroupIds, setPinnedGroupIds] = useState([]); + + const isTextEditable = isTextEditableSelection(element); + const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other"; + + return ( +
+
+ ); +} diff --git a/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx b/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx new file mode 100644 index 0000000000..0ae07968f4 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderFooter(overrides: Partial[0]> = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + return { host, root }; +} + +describe("PropertyPanelFlatFooter", () => { + it("renders the ask-agent affordance and fires onAskAgent on click", () => { + const onAskAgent = vi.fn(); + const { host, root } = renderFooter({ onAskAgent }); + expect(host.textContent).toContain("Ask agent about this element"); + const askButton = host.querySelector('[data-flat-footer-ask="true"]'); + act(() => askButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onAskAgent).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("shows the idle record affordance and toggles recording on click", () => { + const onToggleRecording = vi.fn(); + const { host, root } = renderFooter({ recordingState: "idle", onToggleRecording }); + const recordButton = host.querySelector('[data-flat-footer-record="true"]'); + expect(recordButton?.title).toBe("Record gesture (R)"); + act(() => recordButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onToggleRecording).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("shows the recording duration while recording", () => { + const { host, root } = renderFooter({ + recordingState: "recording", + recordingDuration: 2.4, + onToggleRecording: vi.fn(), + }); + const recordButton = host.querySelector('[data-flat-footer-record="true"]'); + expect(recordButton?.title).toBe("Stop recording 2.4s"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx b/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx new file mode 100644 index 0000000000..90c898f860 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx @@ -0,0 +1,58 @@ +export function PropertyPanelFlatFooter({ + onAskAgent, + recordingState, + recordingDuration, + onToggleRecording, +}: { + onAskAgent?: () => void; + recordingState?: "idle" | "recording" | "preview"; + recordingDuration?: number; + onToggleRecording?: () => void; +}) { + const recording = recordingState === "recording"; + const recordTitle = recording + ? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s` + : "Record gesture (R)"; + + return ( +
+ + {onToggleRecording && ( + + )} +
+ ); +} diff --git a/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx b/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx new file mode 100644 index 0000000000..2d467db82b --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderHeader(overrides: Partial[0]> = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const props = { + name: "Mono Label", + meta: ".mono-label · div", + elementKind: "text" as const, + hidden: false, + copied: false, + onCopy: vi.fn(), + onClear: vi.fn(), + showUngroup: false, + ...overrides, + }; + act(() => { + root.render(); + }); + return { host, root, props }; +} + +describe("PropertyPanelFlatHeader", () => { + it("renders name, meta, and the mint text-type icon", () => { + const { host, root } = renderHeader(); + expect(host.textContent).toContain("Mono Label"); + expect(host.textContent).toContain(".mono-label · div"); + const icon = host.querySelector('[data-flat-header-icon="true"]'); + expect(icon?.className).toContain("text-panel-accent"); + act(() => root.unmount()); + }); + + it("colors the media icon cyan and the other icon amber", () => { + const { host: mediaHost, root: mediaRoot } = renderHeader({ elementKind: "media" }); + expect(mediaHost.querySelector('[data-flat-header-icon="true"]')?.className).toContain( + "text-panel-media", + ); + act(() => mediaRoot.unmount()); + + const { host: otherHost, root: otherRoot } = renderHeader({ elementKind: "other" }); + expect(otherHost.querySelector('[data-flat-header-icon="true"]')?.className).toContain( + "text-panel-container", + ); + act(() => otherRoot.unmount()); + }); + + it("fires onCopy and onClear from their action buttons", () => { + const { host, root, props } = renderHeader(); + const copy = host.querySelector( + '[aria-label="Copy element info to clipboard"]', + ); + const clear = host.querySelector('[aria-label="Clear selection"]'); + act(() => copy?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + act(() => clear?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(props.onCopy).toHaveBeenCalledTimes(1); + expect(props.onClear).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("only renders Ungroup when showUngroup is true", () => { + const { host: without } = renderHeader({ showUngroup: false }); + expect(without.querySelector('[aria-label="Ungroup"]')).toBeNull(); + + const { host: withUngroup } = renderHeader({ showUngroup: true, onUngroup: vi.fn() }); + expect(withUngroup.querySelector('[aria-label="Ungroup"]')).not.toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx b/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx new file mode 100644 index 0000000000..d56468eb58 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx @@ -0,0 +1,89 @@ +import { Eye, EyeSlash } from "@phosphor-icons/react"; +import { ClipboardList, Film, Square, Type, X } from "../../icons/SystemIcons"; + +const ICON_BY_KIND = { text: Type, media: Film, other: Square } as const; +const ICON_COLOR_BY_KIND = { + text: "text-panel-accent", + media: "text-panel-media", + other: "text-panel-container", +} as const; + +export function PropertyPanelFlatHeader({ + name, + meta, + elementKind, + hidden, + onToggleHidden, + copied, + onCopy, + onClear, + onUngroup, + showUngroup, +}: { + name: string; + meta: string; + elementKind: "text" | "media" | "other"; + hidden: boolean; + onToggleHidden?: () => void; + copied: boolean; + onCopy: () => void; + onClear: () => void; + onUngroup?: () => void; + showUngroup: boolean; +}) { + const Icon = ICON_BY_KIND[elementKind]; + const visibilityLabel = hidden ? "Show element" : "Hide element"; + + return ( +
+ +
+ {name} + {meta} +
+
+ {showUngroup && ( + + )} + {onToggleHidden && ( + + )} + + +
+
+ ); +} diff --git a/packages/studio/src/components/editor/manualEditingAvailability.test.ts b/packages/studio/src/components/editor/manualEditingAvailability.test.ts index acd1f06940..2b36fe6406 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.test.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.test.ts @@ -105,4 +105,14 @@ describe("manual editing availability", () => { expect(resolveStudioBooleanEnvFlag({ EMPTY: "" }, ["EMPTY"], true)).toBe(true); expect(resolveStudioBooleanEnvFlag({ UNKNOWN: "maybe" }, ["UNKNOWN"], false)).toBe(false); }); + + it("defaults the flat inspector flag to false and honors an explicit override", async () => { + const off = await loadAvailabilityWithEnv({}); + expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false); + + const on = await loadAvailabilityWithEnv({ + VITE_STUDIO_FLAT_INSPECTOR_ENABLED: "true", + }); + expect(on.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(true); + }); }); diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index b49a0523ca..5a9c802347 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -97,4 +97,13 @@ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag( true, ); +// Studio inspector redesign ("Ledger, flat" — design_handoff_studio_inspector): +// flat identity header/footer/groups behind a flag for incremental review. +// Default false; enable via VITE_STUDIO_FLAT_INSPECTOR_ENABLED=true. +export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_ENABLE_FLAT_INSPECTOR", "VITE_STUDIO_FLAT_INSPECTOR_ENABLED"], + false, +); + export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/components/editor/propertyPanelColor.test.tsx b/packages/studio/src/components/editor/propertyPanelColor.test.tsx new file mode 100644 index 0000000000..f49c22386c --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColor.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ColorField } from "./propertyPanelColor"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("ColorField flat trigger", () => { + it("renders label and value inline with a small swatch, no boxed border", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + const trigger = host.querySelector('[data-flat-color-trigger="true"]'); + expect(trigger).not.toBeNull(); + expect(trigger?.className).not.toContain("border-neutral-800"); + expect(host.textContent).toContain("Color"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelColor.tsx b/packages/studio/src/components/editor/propertyPanelColor.tsx index 962f0e9172..ea6dbb6008 100644 --- a/packages/studio/src/components/editor/propertyPanelColor.tsx +++ b/packages/studio/src/components/editor/propertyPanelColor.tsx @@ -121,11 +121,13 @@ export function ColorField({ label, value, disabled, + flat, onCommit, }: { label: string; value: string; disabled?: boolean; + flat?: boolean; onCommit: (nextValue: string) => void; }) { const buttonRef = useRef(null); @@ -349,6 +351,30 @@ export function ColorField({ } }; + if (flat) { + return ( +
+ {label} + + {picker} +
+ ); + } + return (
{label} diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx new file mode 100644 index 0000000000..33b71d41e9 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + FlatGroup, + FlatRow, + FlatSegmentedRow, + PinnedZoneDivider, +} from "./propertyPanelFlatPrimitives"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderInto(node: React.ReactElement) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + return { host, root }; +} + +describe("FlatRow", () => { + it("renders the default tier with no reset button", () => { + const { host, root } = renderInto( + , + ); + const value = host.querySelector('[data-flat-row-value="true"]'); + expect(value?.className).toContain("text-panel-text-3"); + expect(host.querySelector('[data-flat-row-reset="true"]')).toBeNull(); + act(() => root.unmount()); + }); + + it("renders the explicitCustom tier with a mint value and a reset button", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const value = host.querySelector('[data-flat-row-value="true"]'); + expect(value?.className).toContain("text-panel-accent"); + const reset = host.querySelector('[data-flat-row-reset="true"]'); + expect(reset).not.toBeNull(); + act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("commits edits through the underlying CommitField input", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const input = host.querySelector("input"); + if (!input) throw new Error("expected an input"); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, "24px"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + act(() => { + input.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(onCommit).toHaveBeenCalledWith("24px"); + act(() => root.unmount()); + }); +}); + +describe("FlatSegmentedRow", () => { + it("underlines the active option in mint and leaves others muted", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const options = host.querySelectorAll('[data-flat-segment="true"]'); + expect(options).toHaveLength(2); + expect((options[0] as HTMLElement).className).toContain("text-panel-text-4"); + expect((options[1] as HTMLElement).className).toContain("border-panel-accent"); + act(() => + (options[0] as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true })), + ); + expect(onChange).toHaveBeenCalledWith("left"); + act(() => root.unmount()); + }); +}); + +describe("FlatGroup", () => { + it("renders the open header (name + pin + caret) and shows children", () => { + const onToggleOpen = vi.fn(); + const onTogglePin = vi.fn(); + const { host, root } = renderInto( + +
body
+
, + ); + expect(host.querySelector('[data-testid="body"]')).not.toBeNull(); + const pin = host.querySelector('[data-flat-group-pin="true"]'); + act(() => pin?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onTogglePin).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("renders the collapsed row (name + summary + caret-right) and no children", () => { + const onToggleOpen = vi.fn(); + const { host, root } = renderInto( + +
body
+
, + ); + expect(host.querySelector('[data-testid="body"]')).toBeNull(); + expect(host.textContent).toContain("fill none · 100%"); + const row = host.querySelector('[data-flat-group-collapsed="true"]'); + act(() => row?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onToggleOpen).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); +}); + +describe("PinnedZoneDivider", () => { + it("renders the 'one open below' label", () => { + const { host, root } = renderInto(); + expect(host.textContent).toContain("one open below"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx new file mode 100644 index 0000000000..067ecb7a7b --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -0,0 +1,235 @@ +import { type ReactNode } from "react"; +import { RotateCcw } from "../../icons/SystemIcons"; +import { CommitField } from "./propertyPanelPrimitives"; +import { + VALUE_TIER_LABEL_CLASS, + VALUE_TIER_VALUE_CLASS, + type PropertyValueTier, +} from "./propertyPanelValueTier"; + +/* ------------------------------------------------------------------ */ +/* FlatRow — single-column label/value property row */ +/* ------------------------------------------------------------------ */ + +export function FlatRow({ + label, + value, + tier, + disabled, + liveCommit, + suffix, + dropdown, + onCommit, + onReset, +}: { + label: string; + value: string; + tier: PropertyValueTier; + disabled?: boolean; + liveCommit?: boolean; + suffix?: ReactNode; + /** Renders a trailing 10px caret-down, for select-backed rows. */ + dropdown?: boolean; + onCommit: (nextValue: string) => void; + onReset?: () => void; +}) { + return ( +
+ {label} + + + + + {suffix} + {tier === "explicitCustom" && onReset && ( + + )} + {dropdown && ( + + + + )} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* FlatSegmentedRow — inline glyph runs, no container background */ +/* ------------------------------------------------------------------ */ + +export interface FlatSegmentOption { + key: string; + node: ReactNode; + active: boolean; +} + +export function FlatSegmentedRow({ + label, + options, + disabled, + /** Index (0-based) after which to render a 12px spacer — for combined rows + * like Text's "Case · Style", which pack two independent option groups. */ + spacerAfterIndex, + onChange, +}: { + label: string; + options: FlatSegmentOption[]; + disabled?: boolean; + spacerAfterIndex?: number; + onChange: (nextKey: string) => void; +}) { + return ( +
+ {label} + + {options.map((option, index) => ( + + + {spacerAfterIndex === index && + ))} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* FlatGroup — one-open-at-a-time accordion group (controlled) */ +/* ------------------------------------------------------------------ */ + +export function FlatGroup({ + title, + isOpen, + isPinned, + onToggleOpen, + onTogglePin, + accessory, + summary, + children, +}: { + title: string; + isOpen: boolean; + isPinned: boolean; + onToggleOpen: () => void; + onTogglePin: () => void; + accessory?: ReactNode; + summary?: string; + children: ReactNode; +}) { + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+ {title} + + {accessory} + + + +
+ {children} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* PinnedZoneDivider */ +/* ------------------------------------------------------------------ */ + +export function PinnedZoneDivider() { + return ( +
+
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx new file mode 100644 index 0000000000..85685d5918 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -0,0 +1,247 @@ +import { Plus } from "../../icons/SystemIcons"; +import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; +import type { ImportedFontAsset } from "./fontAssets"; +import { normalizeTextMetricValue } from "./propertyPanelHelpers"; +import { ColorField } from "./propertyPanelColor"; +import { FontFamilyField } from "./propertyPanelFont"; +import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives"; +import { + resolveValueTier, + VALUE_TIER_LABEL_CLASS, + VALUE_TIER_VALUE_CLASS, +} from "./propertyPanelValueTier"; +import { + detectAvailableWeights, + getTextFieldColor, + getTextStyleValue, + TextAreaField, + TextSection, + WEIGHT_LABELS, +} from "./propertyPanelSections"; + +/* ------------------------------------------------------------------ */ +/* Flat text section (design_handoff_studio_inspector, #10a) */ +/* ------------------------------------------------------------------ */ + +const ALIGN_OPTIONS = [ + { key: "left", label: "left", node: "L" }, + { key: "center", label: "center", node: "C" }, + { key: "right", label: "right", node: "R" }, + { key: "justify", label: "justify", node: "J" }, +]; + +const CASE_OPTIONS = [ + { key: "none", node: "–" }, + { key: "uppercase", node: "AG" }, + { key: "lowercase", node: "ag" }, +]; + +function FlatTextFieldEditor({ + field, + styles, + fontAssets, + onImportFonts, + onSetText, + onSetTextFieldStyle, +}: { + field: DomEditSelection["textFields"][number]; + styles: Record; + fontAssets: ImportedFontAsset[]; + onImportFonts?: (files: FileList | File[]) => Promise; + onSetText: (value: string, fieldKey?: string) => void; + onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; +}) { + const weight = getTextStyleValue(field, styles, "font-weight", "400"); + const weightOptions = detectAvailableWeights( + field.computedStyles["font-family"] || styles["font-family"] || "", + ); + const align = getTextStyleValue(field, styles, "text-align", "start"); + const textTransform = getTextStyleValue(field, styles, "text-transform", "none"); + const fontStyle = getTextStyleValue(field, styles, "font-style", "normal"); + + return ( + <> + onSetText(next, field.key)} + /> + onSetTextFieldStyle(field.key, "font-family", next)} + /> + onSetTextFieldStyle(field.key, "font-size", next)} + /> +
+ + Weight + + +
+ + onSetTextFieldStyle( + field.key, + "letter-spacing", + normalizeTextMetricValue("letter-spacing", next), + ) + } + onReset={() => onSetTextFieldStyle(field.key, "letter-spacing", "")} + /> + + onSetTextFieldStyle( + field.key, + "line-height", + normalizeTextMetricValue("line-height", next), + ) + } + onReset={() => onSetTextFieldStyle(field.key, "line-height", "")} + /> + ({ + key: option.key, + node: option.node, + active: align === option.key || (option.key === "left" && align === "start"), + }))} + onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)} + /> + ({ + key: option.key, + node: option.node, + active: textTransform === option.key, + })), + { key: "normal", node: "A", active: fontStyle === "normal" }, + { key: "italic", node: "A", active: fontStyle === "italic" }, + ]} + spacerAfterIndex={2} + onChange={(next) => { + if (next === "normal" || next === "italic") { + onSetTextFieldStyle(field.key, "font-style", next); + } else { + onSetTextFieldStyle(field.key, "text-transform", next); + } + }} + /> + onSetTextFieldStyle(field.key, "color", next)} + /> + + ); +} + +export function FlatTextSection({ + element, + styles, + fontAssets, + onImportFonts, + onSetText, + onSetTextFieldStyle, + onAddTextField, + onRemoveTextField, +}: { + element: DomEditSelection; + styles: Record; + fontAssets: ImportedFontAsset[]; + onImportFonts?: (files: FileList | File[]) => Promise; + onSetText: (value: string, fieldKey?: string) => void; + onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; + onAddTextField: (afterFieldKey?: string) => string | Promise | null; + onRemoveTextField: (fieldKey: string) => void; +}) { + if (!isTextEditableSelection(element)) return null; + const textFields = element.textFields; + const activeField = textFields[0]; + if (!activeField) return null; + + if (textFields.length > 1) { + // The parent FlatGroup (PropertyPanelFlat) already renders a "Text" + // heading around this section — suppress TextSection's own internal + // heading so the flat panel doesn't show "Text" twice in a row. + return ( + + ); + } + + return ( +
+ + +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFont.test.tsx b/packages/studio/src/components/editor/propertyPanelFont.test.tsx new file mode 100644 index 0000000000..fe4d32a684 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFont.test.tsx @@ -0,0 +1,30 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FontFamilyField } from "./propertyPanelFont"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("FontFamilyField flat trigger", () => { + it("renders as a label/value row with a trailing dropdown caret, no boxed border", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const trigger = host.querySelector('[data-flat-font-trigger="true"]'); + expect(trigger).not.toBeNull(); + expect(trigger?.className).not.toContain("border-neutral-800"); + expect(host.textContent).toContain("JetBrains Mono"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFont.tsx b/packages/studio/src/components/editor/propertyPanelFont.tsx index a0fd50d7ab..47f9cce4c7 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.tsx @@ -123,12 +123,14 @@ function loadImportedFontStylesheet(asset: ImportedFontAsset): void { export function FontFamilyField({ value, disabled, + flat, importedFonts, onImportFonts, onCommit, }: { value: string; disabled?: boolean; + flat?: boolean; importedFonts: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; onCommit: (nextValue: string) => void; @@ -366,6 +368,130 @@ export function FontFamilyField({ setOpen(false); }; + const dropdown = open && ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + } + if (e.key === "Enter" && filteredOptions[0]) { + e.preventDefault(); + commitFamily(filteredOptions[0]); + } + }} + className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600" + /> + {canQueryLocalFonts && ( + + )} + + { + await handleImportFonts(event.target.files); + event.target.value = ""; + }} + /> +
+ {fontNotice && ( +
+ {fontNotice} +
+ )} +
+ {filteredOptions.length === 0 ? ( +
No fonts found.
+ ) : ( + filteredOptions.map((option) => ( + + )) + )} +
+
+ ); + + if (flat) { + return ( +
+ Font + + {dropdown} +
+ ); + } + return (
Font family @@ -385,98 +511,7 @@ export function FontFamilyField({ Font - - {open && ( -
-
- setQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - e.preventDefault(); - setOpen(false); - } - if (e.key === "Enter" && filteredOptions[0]) { - e.preventDefault(); - commitFamily(filteredOptions[0]); - } - }} - className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600" - /> - {canQueryLocalFonts && ( - - )} - - { - await handleImportFonts(event.target.files); - event.target.value = ""; - }} - /> -
- {fontNotice && ( -
- {fontNotice} -
- )} -
- {filteredOptions.length === 0 ? ( -
No fonts found.
- ) : ( - filteredOptions.map((option) => ( - - )) - )} -
-
- )} + {dropdown}
); } diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 0032c7ca30..993ea77c05 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/propertyPanelPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx index ae12435607..d8a36eeef1 100644 --- a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers"; -function CommitField({ +export function CommitField({ value, disabled, liveCommit, diff --git a/packages/studio/src/components/editor/propertyPanelSections.test.tsx b/packages/studio/src/components/editor/propertyPanelSections.test.tsx new file mode 100644 index 0000000000..fbcff5e0f9 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelSections.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatTextSection } from "./propertyPanelFlatTextSection"; +import type { DomEditSelection } from "./domEditingTypes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function makeElement(overrides: Partial = {}): DomEditSelection { + 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: { "letter-spacing": "3.96px" }, + computedStyles: { + "font-family": "JetBrains Mono", + "font-size": "22px", + "font-weight": "400", + "letter-spacing": "3.96px", + "line-height": "normal", + "text-align": "right", + "text-transform": "none", + "font-style": "normal", + color: "rgb(255, 176, 32)", + }, + source: "self", + }, + ], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + } as DomEditSelection; +} + +function renderSection(overrides: Partial = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeElement(overrides); + act(() => { + root.render( + , + ); + }); + return { host, root }; +} + +describe("FlatTextSection", () => { + it("renders the content block and every row from #10a", () => { + const { host, root } = renderSection(); + expect(host.textContent).toContain("PACKETS / FRAME"); + expect(host.textContent).toContain("Font"); + expect(host.textContent).toContain("Weight"); + expect(host.textContent).toContain("Letter spacing"); + expect(host.textContent).toContain("Line height"); + expect(host.textContent).toContain("Align"); + act(() => root.unmount()); + }); + + it("colors letter-spacing mint (explicit, differs from 0px default) with a reset button", () => { + const { host, root } = renderSection(); + const resetButtons = host.querySelectorAll('[data-flat-row-reset="true"]'); + expect(resetButtons.length).toBeGreaterThan(0); + act(() => root.unmount()); + }); + + it("commits a font-weight change through onSetTextFieldStyle", () => { + const onSetTextFieldStyle = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeElement(); + act(() => { + root.render( + , + ); + }); + const select = host.querySelector("select"); + if (!select) throw new Error("expected a weight