From 5aacbe3fc8f940c2ba19d71690f968bf988a49db Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 16:13:58 -0700 Subject: [PATCH 1/8] feat(studio): persist per-element-type pinned groups in studioUiPreferences Co-Authored-By: Claude Sonnet 5 --- .../src/utils/studioUiPreferences.test.ts | 45 +++++++++++++++++++ .../studio/src/utils/studioUiPreferences.ts | 10 +++++ 2 files changed, 55 insertions(+) diff --git a/packages/studio/src/utils/studioUiPreferences.test.ts b/packages/studio/src/utils/studioUiPreferences.test.ts index c5dbd50968..c5c0733c54 100644 --- a/packages/studio/src/utils/studioUiPreferences.test.ts +++ b/packages/studio/src/utils/studioUiPreferences.test.ts @@ -50,3 +50,48 @@ describe("studio UI preferences", () => { }); }); }); + +function fakeStorage(): Storage { + const map = new Map(); + return { + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + clear: () => map.clear(), + key: () => null, + get length() { + return map.size; + }, + } as Storage; +} + +describe("pinnedGroupsByElementType", () => { + it("round-trips a per-element-type pin map", () => { + const storage = fakeStorage(); + writeStudioUiPreferences( + { pinnedGroupsByElementType: { text: ["motion"], media: ["grade"] } }, + storage, + ); + const read = readStudioUiPreferences(storage); + expect(read.pinnedGroupsByElementType).toEqual({ text: ["motion"], media: ["grade"] }); + }); + + it("ignores a malformed pinnedGroupsByElementType (non-object, or non-string-array values)", () => { + const storage = fakeStorage(); + storage.setItem( + "hf-studio-ui-preferences", + JSON.stringify({ pinnedGroupsByElementType: { text: "not-an-array", media: [1, 2] } }), + ); + const read = readStudioUiPreferences(storage); + expect(read.pinnedGroupsByElementType).toEqual({ media: [] }); + }); + + it("merges a pin-map patch without clobbering other preferences", () => { + const storage = fakeStorage(); + writeStudioUiPreferences({ audioMuted: true }, storage); + writeStudioUiPreferences({ pinnedGroupsByElementType: { text: ["style"] } }, storage); + const read = readStudioUiPreferences(storage); + expect(read.audioMuted).toBe(true); + expect(read.pinnedGroupsByElementType).toEqual({ text: ["style"] }); + }); +}); diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts index 13804959a6..ca7f432e79 100644 --- a/packages/studio/src/utils/studioUiPreferences.ts +++ b/packages/studio/src/utils/studioUiPreferences.ts @@ -16,6 +16,7 @@ export interface StudioUiPreferences { gridVisible?: boolean; gridSpacing?: number; snapToGrid?: boolean; + pinnedGroupsByElementType?: Record; } const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences"; @@ -88,6 +89,15 @@ function readStorage(storage: Storage | null): StudioUiPreferences { if (typeof parsed.snapToGrid === "boolean") { preferences.snapToGrid = parsed.snapToGrid; } + if (isRecord(parsed.pinnedGroupsByElementType)) { + const map: Record = {}; + for (const [kind, ids] of Object.entries(parsed.pinnedGroupsByElementType)) { + if (Array.isArray(ids)) { + map[kind] = ids.filter((id): id is string => typeof id === "string"); + } + } + preferences.pinnedGroupsByElementType = map; + } return preferences; } catch { return {}; From 60a5268034744e70c382e420df69bfaccbbb807e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 16:19:55 -0700 Subject: [PATCH 2/8] feat(studio): add usePersistedPinnedGroups hook Reads/writes the per-element-kind pinned-groups map added to studioUiPreferences in the prior task, read-modify-writing the whole map since writeStudioUiPreferences only shallow-merges top-level keys. --- .../hooks/usePersistedPinnedGroups.test.ts | 68 +++++++++++++++++++ .../src/hooks/usePersistedPinnedGroups.ts | 26 +++++++ 2 files changed, 94 insertions(+) create mode 100644 packages/studio/src/hooks/usePersistedPinnedGroups.test.ts create mode 100644 packages/studio/src/hooks/usePersistedPinnedGroups.ts diff --git a/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts b/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts new file mode 100644 index 0000000000..f233bae7ba --- /dev/null +++ b/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { usePersistedPinnedGroups } from "./usePersistedPinnedGroups"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; + window.localStorage.clear(); +}); + +function Harness({ + elementKind, + onReady, +}: { + elementKind: string; + onReady: (api: ReturnType) => void; +}) { + const api = usePersistedPinnedGroups(elementKind); + onReady(api); + return null; +} + +function mount(elementKind: string) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + let api!: ReturnType; + act(() => { + root.render(React.createElement(Harness, { elementKind, onReady: (a) => (api = a) })); + }); + return { + host, + root, + get api() { + return api; + }, + }; +} + +describe("usePersistedPinnedGroups", () => { + it("starts empty, toggling a pin adds it, toggling again removes it", () => { + const m = mount("text"); + expect(m.api.pinnedGroupIds).toEqual([]); + act(() => m.api.togglePin("motion")); + expect(m.api.pinnedGroupIds).toEqual(["motion"]); + act(() => m.api.togglePin("motion")); + expect(m.api.pinnedGroupIds).toEqual([]); + act(() => m.root.unmount()); + }); + + it("persists across remounts, scoped per element kind", () => { + const first = mount("text"); + act(() => first.api.togglePin("motion")); + act(() => first.root.unmount()); + + const secondSameKind = mount("text"); + expect(secondSameKind.api.pinnedGroupIds).toEqual(["motion"]); + act(() => secondSameKind.root.unmount()); + + const thirdOtherKind = mount("media"); + expect(thirdOtherKind.api.pinnedGroupIds).toEqual([]); + act(() => thirdOtherKind.root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/usePersistedPinnedGroups.ts b/packages/studio/src/hooks/usePersistedPinnedGroups.ts new file mode 100644 index 0000000000..57e364f2f0 --- /dev/null +++ b/packages/studio/src/hooks/usePersistedPinnedGroups.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from "react"; +import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences"; + +export function usePersistedPinnedGroups(elementKind: string) { + const [pinnedGroupIds, setPinnedGroupIds] = useState( + () => readStudioUiPreferences().pinnedGroupsByElementType?.[elementKind] ?? [], + ); + + const togglePin = useCallback( + (groupId: string) => { + setPinnedGroupIds((current) => { + const next = current.includes(groupId) + ? current.filter((id) => id !== groupId) + : [...current, groupId]; + const existing = readStudioUiPreferences().pinnedGroupsByElementType ?? {}; + writeStudioUiPreferences({ + pinnedGroupsByElementType: { ...existing, [elementKind]: next }, + }); + return next; + }); + }, + [elementKind], + ); + + return { pinnedGroupIds, togglePin }; +} From c4a9f0c5bd8e0d3250a4a2c0921ec5f100c086c5 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 17:31:23 -0700 Subject: [PATCH 3/8] feat(studio): add PinnedGroupRow always-open pinned-group primitive --- .../propertyPanelFlatPrimitives.test.tsx | 19 ++++++++ .../editor/propertyPanelFlatPrimitives.tsx | 44 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index 6ff132760e..dccf1aa232 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -10,6 +10,7 @@ import { FlatSelectRow, FlatSlider, FlatToggle, + PinnedGroupRow, PinnedZoneDivider, } from "./propertyPanelFlatPrimitives"; @@ -456,3 +457,21 @@ describe("FlatToggle", () => { act(() => root.unmount()); }); }); + +describe("PinnedGroupRow", () => { + it("renders a 'Pinned' badge, filled pin icon, and always shows children", () => { + const onUnpin = vi.fn(); + const { host, root } = renderInto( + +
body
+
, + ); + expect(host.textContent).toContain("Pinned"); + expect(host.textContent).toContain("Motion"); + expect(host.querySelector('[data-testid="body"]')).not.toBeNull(); + const unpin = host.querySelector('[data-pinned-group-unpin="true"]'); + act(() => unpin?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onUnpin).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 078845bd1a..21f02295f5 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -449,3 +449,47 @@ export function FlatToggle({ ); } + +/* ------------------------------------------------------------------ */ +/* PinnedGroupRow — always-open pinned group (design_handoff #8a) */ +/* ------------------------------------------------------------------ */ + +export function PinnedGroupRow({ + title, + accessory, + onUnpin, + children, +}: { + title: string; + accessory?: ReactNode; + onUnpin: () => void; + children: ReactNode; +}) { + return ( +
+
+ + + Pinned + + {title} + + + {accessory} + + +
+ {children} +
+ ); +} From 0cce67020ac8d1c9a347f7c24dd9166f25692c2f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 17:42:27 -0700 Subject: [PATCH 4/8] feat(studio): render pinned groups first with a persisted, per-element-kind pin zone --- .../components/editor/PropertyPanel.test.tsx | 45 +++ .../components/editor/PropertyPanelFlat.tsx | 334 ++++++++++-------- 2 files changed, 224 insertions(+), 155 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 12ee793688..b2671a45e8 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -18,6 +18,10 @@ vi.mock("../../contexts/StudioContext", async () => { afterEach(() => { document.body.innerHTML = ""; + // usePersistedPinnedGroups persists to localStorage; clear it so a pinned + // group from one test can't leak into the next (which would move a group out + // of the accordion and break an unrelated open-by-default assertion). + window.localStorage.clear(); vi.doUnmock("./manualEditingAvailability"); vi.resetModules(); }); @@ -751,3 +755,44 @@ describe("PropertyPanel — Media group (Plan 4)", () => { RENDER_TIMEOUT_MS, ); }); + +describe("PropertyPanel — pinning", () => { + it( + "renders a pinned group first, always open, above the PinnedZoneDivider", + async () => { + const { host, root } = await renderPanel(true); + // Pin the Text group via its pin button. + const pinButton = host.querySelector('[data-flat-group-pin="true"]'); + if (!pinButton) throw new Error("expected a pin button on the open Text group"); + act(() => pinButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + const pinnedRow = host.querySelector('[data-pinned-group="true"]'); + expect(pinnedRow?.textContent).toContain("Text"); + expect(pinnedRow?.textContent).toContain("Pinned"); + + // The divider must appear after the pinned zone. + const container = host.querySelector(".flex-1.overflow-y-auto"); + const children = Array.from(container?.children ?? []); + const pinnedIndex = children.indexOf(pinnedRow as Element); + const dividerIndex = children.findIndex((el) => el.textContent?.includes("one open below")); + expect(pinnedIndex).toBeGreaterThanOrEqual(0); + expect(dividerIndex).toBeGreaterThan(pinnedIndex); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "unpinning returns the group to its normal stack position and it closes", + async () => { + const { host, root } = await renderPanel(true); + const pinButton = host.querySelector('[data-flat-group-pin="true"]'); + act(() => pinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const unpinButton = host.querySelector('[data-pinned-group-unpin="true"]'); + act(() => unpinButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(host.querySelector('[data-pinned-group="true"]')).toBeNull(); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 5656f33293..efa15347f0 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { resolveEditingSections } from "@hyperframes/core/editing"; import type { DomEditSelection } from "./domEditing"; import { isTextEditableSelection } from "./domEditing"; @@ -6,7 +6,7 @@ import type { PropertyPanelProps } from "./propertyPanelHelpers"; import { formatPxMetricValue } from "./propertyPanelHelpers"; import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader"; import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter"; -import { FlatGroup } from "./propertyPanelFlatPrimitives"; +import { FlatGroup, PinnedGroupRow, PinnedZoneDivider } from "./propertyPanelFlatPrimitives"; import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; @@ -18,6 +18,7 @@ import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections"; import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability"; import { ColorGradingSection } from "./propertyPanelColorGradingSection"; import { useColorGradingController } from "./useColorGradingController"; +import { usePersistedPinnedGroups } from "../../hooks/usePersistedPinnedGroups"; import { FlatColorGradingAccessory, FlatColorGradingSection, @@ -25,6 +26,14 @@ import { type EditingSections = ReturnType; +type FlatGroupDescriptor = { + id: string; + title: string; + summary?: string; + accessory?: ReactNode; + content: ReactNode; +}; + // Type-only fallback for the Motion effect-card callbacks. Used solely to // satisfy FlatMotionSection's required-callback shape when the effect list is // gated off (showEffects === false, so none of these are ever invoked). Keeps @@ -228,7 +237,6 @@ export function PropertyPanelFlat({ ? "media" : "layout", ); - const [pinnedGroupIds, setPinnedGroupIds] = useState([]); // Grade group state. Called unconditionally (React rules-of-hooks) even when // sections.colorGrading is false — unlike the legacy ColorGradingSection, @@ -246,12 +254,9 @@ export function PropertyPanelFlat({ const isTextEditable = isTextEditableSelection(element); const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other"; + const { pinnedGroupIds, togglePin } = usePersistedPinnedGroups(elementKind); const toggleOpen = (groupId: string) => setOpenGroupId((current) => (current === groupId ? "" : groupId)); - const togglePin = (groupId: string) => - setPinnedGroupIds((current) => - current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId], - ); // Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) — // must agree with Motion's Timing row (FlatTimingRow), which infers the // range from animations when there's no explicit data-duration. Computed @@ -307,6 +312,152 @@ export function PropertyPanelFlat({ const showMotionEffects = gsapEffectHandlers !== null; const showMotionGroup = showMotionTiming || showMotionEffects; + // Ordered group descriptors — one per FlatGroup this panel renders, gated by + // the same conditions the inline JSX used. Partitioned into pinned/unpinned + // below so pinned groups render first (always open, no accordion) above the + // PinnedZoneDivider, with the rest in the one-open accordion beneath it. + const groups: FlatGroupDescriptor[] = []; + if (isTextEditable) { + groups.push({ + id: "text", + title: "Text", + summary: formatTextFieldPreview(element.textFields[0]?.value ?? ""), + content: ( + + ), + }); + } + if (showEditableSections) { + groups.push({ + id: "style", + title: "Style", + summary: `fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`, + content: ( + + ), + }); + } + groups.push({ + id: "layout", + title: "Layout", + accessory: drag values to scrub, + summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`, + content: ( + + ), + }); + if (showMotionGroup) { + groups.push({ + id: "motion", + title: "Motion", + summary: `${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`, + content: ( + + ), + }); + } + if (sections.colorGrading) { + groups.push({ + id: "grade", + title: "Grade", + accessory: , + summary: `${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`, + content: ( + void colorGradingController.applyToScope()} + onApplyScopeAvailable={Boolean(onApplyColorGradingScope)} + mediaMetadata={colorGradingController.mediaMetadata} + /> + ), + }); + } + if (sections.media) { + groups.push({ + id: "media", + title: "Media", + summary: element.tagName, + content: ( + + ), + }); + } + + const pinned = groups.filter((g) => pinnedGroupIds.includes(g.id)); + const unpinned = groups.filter((g) => !pinnedGroupIds.includes(g.id)); + return (
- {isTextEditable && ( - toggleOpen("text")} - onTogglePin={() => togglePin("text")} - summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")} + {pinned.map((g) => ( + togglePin(g.id)} > - - - )} - - {showEditableSections && ( + {g.content} + + ))} + {pinned.length > 0 && unpinned.length > 0 && } + {unpinned.map((g) => ( toggleOpen("style")} - onTogglePin={() => togglePin("style")} - summary={`fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`} + key={g.id} + title={g.title} + isOpen={openGroupId === g.id} + isPinned={false} + onToggleOpen={() => toggleOpen(g.id)} + onTogglePin={() => togglePin(g.id)} + summary={g.summary} + accessory={g.accessory} > - + {g.content} - )} - - toggleOpen("layout")} - onTogglePin={() => togglePin("layout")} - accessory={drag values to scrub} - summary={`${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`} - > - - - - {showMotionGroup && ( - toggleOpen("motion")} - onTogglePin={() => togglePin("motion")} - summary={`${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`} - > - - - )} - {sections.colorGrading && ( - toggleOpen("grade")} - onTogglePin={() => togglePin("grade")} - accessory={} - summary={`${colorGradingController.grading.preset ?? "neutral"} · ${Math.round(colorGradingController.grading.intensity * 100)}%`} - > - void colorGradingController.applyToScope()} - onApplyScopeAvailable={Boolean(onApplyColorGradingScope)} - mediaMetadata={colorGradingController.mediaMetadata} - /> - - )} + ))} {sections.colorGrading && ( )} - {sections.media && ( - toggleOpen("media")} - onTogglePin={() => togglePin("media")} - summary={element.tagName} - > - - - )} {showEditableSections && ( Date: Thu, 9 Jul 2026 17:49:19 -0700 Subject: [PATCH 5/8] test(studio): rename unpin test to match what it actually verifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the pin-aware group list refactor flagged the test name claiming the group "closes" on unpin — it doesn't assert that, and structurally the group re-opens (togglePin never touches openGroupId). Retitled to describe only the return-to-stack behavior actually tested. Co-Authored-By: Claude Sonnet 5 --- packages/studio/src/components/editor/PropertyPanel.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index b2671a45e8..27007860b5 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -783,7 +783,7 @@ describe("PropertyPanel — pinning", () => { ); it( - "unpinning returns the group to its normal stack position and it closes", + "unpinning returns the group to its normal accordion stack position", async () => { const { host, root } = await renderPanel(true); const pinButton = host.querySelector('[data-flat-group-pin="true"]'); From 8e701e4e9fab827da852b1df010347ceda28ecff Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 18:03:06 -0700 Subject: [PATCH 6/8] feat(studio): add FlatTextLayerList for multi-field text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Originated layout, no design mock exists — flag for design review. Co-Authored-By: Claude Sonnet 5 --- .../propertyPanelFlatTextSection.test.tsx | 84 ++++++++++++++++++ .../editor/propertyPanelFlatTextSection.tsx | 85 ++++++++++++++++++- 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx new file mode 100644 index 0000000000..b3c5f10a8e --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatTextLayerList } from "./propertyPanelFlatTextSection"; + +(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 }; +} + +const FIELDS = [ + { + key: "a", + label: "Text", + value: "Headline", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self" as const, + }, + { + key: "b", + label: "Text", + value: "Subhead", + tagName: "span", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self" as const, + }, +]; + +describe("FlatTextLayerList", () => { + it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => { + const onSelect = vi.fn(); + const onAdd = vi.fn(); + const onRemove = vi.fn(); + const { host, root } = renderInto( + , + ); + expect(host.textContent).toContain("Headline"); + expect(host.textContent).toContain("Subhead"); + + const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(2); + expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true"); + expect((rows[1] as HTMLElement).getAttribute("data-active")).toBe("false"); + + act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSelect).toHaveBeenCalledWith("b"); + + const addButton = host.querySelector('[data-flat-text-layer-add="true"]'); + act(() => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onAdd).toHaveBeenCalledTimes(1); + + const removeButton = host.querySelector( + '[data-flat-text-layer-remove="true"]', + ); + act(() => removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onRemove).toHaveBeenCalledWith("a"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx index 85685d5918..fd0c14090b 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -1,4 +1,4 @@ -import { Plus } from "../../icons/SystemIcons"; +import { Plus, X } from "../../icons/SystemIcons"; import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; import type { ImportedFontAsset } from "./fontAssets"; import { normalizeTextMetricValue } from "./propertyPanelHelpers"; @@ -12,6 +12,7 @@ import { } from "./propertyPanelValueTier"; import { detectAvailableWeights, + formatTextFieldPreview, getTextFieldColor, getTextStyleValue, TextAreaField, @@ -245,3 +246,85 @@ export function FlatTextSection({
); } + +/* ------------------------------------------------------------------ */ +/* Multi-field layer list (design_handoff_studio_inspector, #10a — */ +/* no mock exists for this row; layout originated by this plan, */ +/* following the "left-rule nested content" convention established */ +/* by Text's own content block, Motion's effect cards, and Media's */ +/* cutout block. Flag for design review.) */ +/* ------------------------------------------------------------------ */ + +export function FlatTextLayerList({ + fields, + activeFieldKey, + styles, + onSelect, + onAdd, + onRemove, +}: { + fields: DomEditSelection["textFields"]; + activeFieldKey: string; + styles: Record; + onSelect: (fieldKey: string) => void; + onAdd: () => void; + onRemove: (fieldKey: string) => void; +}) { + return ( +
+
+ Text layers +
+
+ {fields.map((field) => { + const active = field.key === activeFieldKey; + return ( +
onSelect(field.key)} + className={`flex min-h-[26px] cursor-pointer items-center gap-2 rounded px-1 ${ + active ? "bg-panel-accent/10" : "hover:bg-panel-hover" + }`} + > + + + {formatTextFieldPreview(field.value) || "Text"} + + + {field.tagName} + + {fields.length > 1 && ( + + )} +
+ ); + })} +
+ +
+ ); +} From 2bb604749a8548fbfcd61600975d6fda5957f96d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 18:11:39 -0700 Subject: [PATCH 7/8] test(studio): assert stopPropagation prevents onSelect on remove click Review proved the existing test didn't catch a broken stopPropagation by temporarily removing it and confirming the suite still passed. Co-Authored-By: Claude Sonnet 5 --- .../components/editor/propertyPanelFlatTextSection.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx index b3c5f10a8e..be526bb2a2 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx @@ -79,6 +79,10 @@ describe("FlatTextLayerList", () => { ); act(() => removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); expect(onRemove).toHaveBeenCalledWith("a"); + // stopPropagation on the remove button must prevent the row's own onClick + // from also firing onSelect for the removed field's key. + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).not.toHaveBeenCalledWith("a"); act(() => root.unmount()); }); }); From 45a06a5f3f72b23bccc685d55e3cb0566b8c0918 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 18:21:29 -0700 Subject: [PATCH 8/8] feat(studio): flatten multi-field Text, retiring TextSection fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlatTextSection's multi-field branch (textFields.length > 1) now renders FlatTextLayerList (Task 5) + the existing single-field FlatTextFieldEditor for the active field, tracked via new local activeFieldKey state that resyncs (useEffect) when the active field disappears from props. This retires the legacy TextSection delegation entirely for that case; the TextSection import is removed from propertyPanelFlatTextSection.tsx since nothing else in the file referenced it. Also updates propertyPanelSections.test.tsx and PropertyPanel.test.tsx, which exercised/documented the old multi-field-falls-back-to-legacy- TextSection behavior in comments and test titles — reworded to describe the new flat path (assertions were already compatible and still pass). Flag for reviewer: hideOwnHeading on the legacy TextSection component (propertyPanelSections.tsx) was added in an earlier plan specifically for this now-removed call site. It has no remaining consumer after this task lands (PropertyPanel.tsx's legacy caller doesn't pass it). Left in place per brief instruction — not deleting unilaterally, since that's a scope decision for whoever reviews this task. Co-Authored-By: Claude Sonnet 5 --- .../components/editor/PropertyPanel.test.tsx | 12 +- .../propertyPanelFlatTextSection.test.tsx | 168 +++++++++++++++++- .../editor/propertyPanelFlatTextSection.tsx | 52 ++++-- .../editor/propertyPanelSections.test.tsx | 12 +- 4 files changed, 215 insertions(+), 29 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 27007860b5..d303768545 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -81,9 +81,10 @@ function nonTextElement() { }; } -// 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). +// Bug 2 fixture: 2+ text fields, which routes FlatTextSection to its own +// flat multi-field layer list (FlatTextLayerList + FlatTextFieldEditor) — +// must not double-render the "Text" heading (FlatGroup's own heading; this +// component never renders one of its own). function multiFieldTextElement() { const base = baseElement(); return { @@ -312,10 +313,11 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => { 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. + // ="text") must never appear, since the flat multi-field path no longer + // delegates to that component at all. 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. + // Content from the flat multi-field layer list must render. expect(host.textContent).toContain("Text layers"); act(() => root.unmount()); }, diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx index be526bb2a2..4ca2cb36db 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx @@ -1,9 +1,10 @@ // @vitest-environment happy-dom -import React, { act } from "react"; +import React, { act, useState } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { FlatTextLayerList } from "./propertyPanelFlatTextSection"; +import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection"; +import type { DomEditSelection, DomEditTextField } from "./domEditingTypes"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -86,3 +87,166 @@ describe("FlatTextLayerList", () => { act(() => root.unmount()); }); }); + +function makeMultiFieldElement(): DomEditSelection { + return { + element: document.createElement("div"), + id: "multi", + selector: ".multi", + label: "Multi", + tagName: "div", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 100, height: 100 }, + textContent: "Headline Subhead", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [ + { + key: "a", + label: "Text", + value: "Headline", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + { + key: "b", + label: "Text", + value: "Subhead", + tagName: "span", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + } as DomEditSelection; +} + +describe("FlatTextSection — multi-field", () => { + it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("Headline"); + expect(host.textContent).toContain("Subhead"); + // Active field's editor rows are visible (Font/Weight/etc. from FlatTextFieldEditor). + expect(host.textContent).toContain("Weight"); + // Exactly one "Text layers" micro-label — this component doesn't duplicate its own list. + const layerLabels = Array.from(host.querySelectorAll("div")).filter( + (el) => el.textContent === "Text layers", + ); + expect(layerLabels.length).toBeLessThanOrEqual(1); + + const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(host.textContent).toContain("Subhead"); + act(() => root.unmount()); + }); + + it("wires onAdd/onRemove end-to-end: async onAddTextField switches the active field once it appears in props, and the resync effect falls back to the first field when the active one disappears", async () => { + let addResolved = false; + + function Harness() { + const [fields, setFields] = useState(makeMultiFieldElement().textFields); + const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields }; + return ( + + Promise.resolve().then(() => { + addResolved = true; + setFields((prev) => [ + ...prev, + { + key: "c", + label: "Text", + value: "Third", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ]); + return "c"; + }) + } + onRemoveTextField={(fieldKey: string) => + setFields((prev) => prev.filter((field) => field.key !== fieldKey)) + } + /> + ); + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + + let rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(2); + expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true"); + + const addButton = host.querySelector('[data-flat-text-layer-add="true"]'); + await act(async () => { + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(addResolved).toBe(true); + rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(3); + expect((rows[2] as HTMLElement).getAttribute("data-active")).toBe("true"); + + // Remove the active field ("c") through the wired onRemoveTextField — the + // resync useEffect must fall back to the first remaining field ("a") + // since "c" no longer exists in element.textFields. + const removeButtons = host.querySelectorAll( + '[data-flat-text-layer-remove="true"]', + ); + act(() => { + removeButtons[2].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + rows = host.querySelectorAll('[data-flat-text-layer-row="true"]'); + expect(rows).toHaveLength(2); + expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true"); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx index fd0c14090b..455053891e 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { Plus, X } from "../../icons/SystemIcons"; import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; import type { ImportedFontAsset } from "./fontAssets"; @@ -16,7 +17,6 @@ import { getTextFieldColor, getTextStyleValue, TextAreaField, - TextSection, WEIGHT_LABELS, } from "./propertyPanelSections"; @@ -201,27 +201,47 @@ export function FlatTextSection({ onAddTextField: (afterFieldKey?: string) => string | Promise | null; onRemoveTextField: (fieldKey: string) => void; }) { + const [activeFieldKey, setActiveFieldKey] = useState( + element.textFields[0]?.key ?? null, + ); + + useEffect(() => { + const nextFields = element.textFields; + setActiveFieldKey((current) => { + if (current && nextFields.some((field) => field.key === current)) return current; + return nextFields[0]?.key ?? null; + }); + }, [element.id, element.selector, element.textFields]); + if (!isTextEditableSelection(element)) return null; const textFields = element.textFields; - const activeField = textFields[0]; + const activeField = textFields.find((field) => field.key === activeFieldKey) ?? 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 ( - +
+ + void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => { + if (nextKey) setActiveFieldKey(nextKey); + }) + } + onRemove={onRemoveTextField} + /> + +
); } diff --git a/packages/studio/src/components/editor/propertyPanelSections.test.tsx b/packages/studio/src/components/editor/propertyPanelSections.test.tsx index fbcff5e0f9..075915ed1d 100644 --- a/packages/studio/src/components/editor/propertyPanelSections.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelSections.test.tsx @@ -133,7 +133,7 @@ describe("FlatTextSection", () => { act(() => root.unmount()); }); - it("suppresses TextSection's own heading when falling back for a multi-field element", () => { + it("renders the flat layer list (not the legacy TextSection) for a multi-field element", () => { const { host, root } = renderSection({ textFields: [ makeElement().textFields[0], @@ -149,12 +149,12 @@ describe("FlatTextSection", () => { }, ], }); - // TextSection's own Section wrapper (data-panel-section="text") must not - // render here — the caller (PropertyPanelFlat's FlatGroup) already shows - // a "Text" heading, so a second one from the legacy component would be a - // doubled heading. + // Legacy TextSection's own Section wrapper (data-panel-section="text") + // must never render here — multi-field elements now go through the flat + // FlatTextLayerList + FlatTextFieldEditor path end to end, not a + // delegation to the legacy component. expect(host.querySelector('[data-panel-section="text"]')).toBeNull(); - // The multi-field fallback's own content must still render. + // The flat layer list's own content must render. expect(host.textContent).toContain("Text layers"); act(() => root.unmount()); });