diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 3e55d15289..0fe2148d27 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -18,10 +18,6 @@ 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(); }); @@ -813,47 +809,6 @@ describe("PropertyPanel — Media group (Plan 4)", () => { ); }); -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('[data-flat-panel-body="true"]'); - 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 accordion stack position", - 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, - ); -}); - // design_handoff scrollable-open-section: collapsed headers before/after the // open group render in normal document flow and never move (no sticky, no // stacking offsets) — only the open group's own body content scrolls, in a @@ -882,8 +837,7 @@ describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)", if (child.matches('[data-flat-group-open="true"]')) return child.textContent ?? ""; return null; }); - // Filter to just the group entries (drop nulls from any divider, none - // expected here since no groups are pinned). + // Filter to just the group entries (drop any non-group nulls). const groupTitles = titles.filter((t): t is string => t !== null); expect(groupTitles).toHaveLength(6); expect(groupTitles[0]).toContain("Text"); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 1a97fe8146..4babca2348 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -265,7 +265,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro 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. + // twice). PropertyPanelFlat owns the one-open group state. return ( 0 ? "text" : "other"; - const { pinnedGroupIds, togglePin } = usePersistedPinnedGroups(elementKind); const toggleOpen = (groupId: string) => setOpenGroupId((current) => (current === groupId ? "" : groupId)); // Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) — @@ -311,9 +309,8 @@ export function PropertyPanelFlat({ 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. + // the same conditions the inline JSX used. Split below into before-open/ + // open/after-open regions for the one-open accordion. const groups: FlatGroupDescriptor[] = []; if (isTextEditable) { groups.push({ @@ -453,9 +450,6 @@ export function PropertyPanelFlat({ }); } - const pinned = groups.filter((g) => pinnedGroupIds.includes(g.id)); - const unpinned = groups.filter((g) => !pinnedGroupIds.includes(g.id)); - // Fixed-headers + scrollable-open-section layout (design_handoff // scrollable-open-section, replaces the prior sticky-stacking mechanism): // collapsed headers before/after the open group render in normal document @@ -463,10 +457,10 @@ export function PropertyPanelFlat({ // a dedicated region between the two fixed header stacks. When no group is // open, every group is just a collapsed header — there's no scrollable // middle region at all, since nothing is expanded. - const openIndex = unpinned.findIndex((g) => g.id === openGroupId); - const beforeOpen = openIndex === -1 ? unpinned : unpinned.slice(0, openIndex); - const openGroup = openIndex === -1 ? null : unpinned[openIndex]; - const afterOpen = openIndex === -1 ? [] : unpinned.slice(openIndex + 1); + const openIndex = groups.findIndex((g) => g.id === openGroupId); + const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex); + const openGroup = openIndex === -1 ? null : groups[openIndex]; + const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1); return (
@@ -487,25 +481,12 @@ export function PropertyPanelFlat({ showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)} />
- {pinned.map((g) => ( - togglePin(g.id)} - > - {g.content} - - ))} - {pinned.length > 0 && unpinned.length > 0 && } {beforeOpen.map((g) => ( toggleOpen(g.id)} - onTogglePin={() => togglePin(g.id)} summary={g.summary} /> ))} @@ -514,9 +495,7 @@ export function PropertyPanelFlat({ toggleOpen(openGroup.id)} - onTogglePin={() => togglePin(openGroup.id)} accessory={openGroup.accessory} />
@@ -529,9 +508,7 @@ export function PropertyPanelFlat({ key={g.id} title={g.title} isOpen={false} - isPinned={false} onToggleOpen={() => toggleOpen(g.id)} - onTogglePin={() => togglePin(g.id)} summary={g.summary} /> ))} diff --git a/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx b/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx index 4722546a98..6b9170e518 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx @@ -69,10 +69,9 @@ describe("PropertyPanelFlatFooter", () => { // rounding gap) — is gone now that nothing above the footer is // `position: sticky`. Live browser verification (p11 report) confirmed the // boundary renders as a single clean hairline without it: whatever - // immediately precedes the footer (a collapsed FlatGroupHeader, the open - // group's scrollable body wrapper, or a PinnedGroupRow) already draws its - // own border-b in normal document flow, so the footer needs no border or - // seal of its own. + // immediately precedes the footer (a collapsed FlatGroupHeader, or the open + // group's scrollable body wrapper) already draws its own border-b in normal + // document flow, so the footer needs no border or seal of its own. it("renders no seal overlay and no border of its own — the boundary line comes from whatever precedes it", () => { const { host, root } = renderFooter({ onAskAgent: vi.fn() }); const footerRoot = host.firstElementChild as HTMLElement; diff --git a/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx b/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx index 66ecd0c056..07b2a28701 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx @@ -17,10 +17,10 @@ export function PropertyPanelFlatFooter({ return ( // No border-t here: every possible element immediately above this footer // in the new fixed-headers + scrollable-open-section layout (a collapsed - // FlatGroupHeader, the open group's scrollable body wrapper, or a - // PinnedGroupRow) already draws its own border-b in normal document flow - // — nothing here is `position: sticky` anymore, so there's no rounding - // seam to seal (see p11-scrollable-open-section-report.md). + // FlatGroupHeader, or the open group's scrollable body wrapper) already + // draws its own border-b in normal document flow — nothing here is + // `position: sticky` anymore, so there's no rounding seam to seal (see + // p11-scrollable-open-section-report.md).
); } - -/* ------------------------------------------------------------------ */ -/* 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} -
- ); -} diff --git a/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts b/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts deleted file mode 100644 index f233bae7ba..0000000000 --- a/packages/studio/src/hooks/usePersistedPinnedGroups.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// @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 deleted file mode 100644 index 57e364f2f0..0000000000 --- a/packages/studio/src/hooks/usePersistedPinnedGroups.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 }; -} diff --git a/packages/studio/src/utils/studioUiPreferences.test.ts b/packages/studio/src/utils/studioUiPreferences.test.ts index c5c0733c54..c5dbd50968 100644 --- a/packages/studio/src/utils/studioUiPreferences.test.ts +++ b/packages/studio/src/utils/studioUiPreferences.test.ts @@ -50,48 +50,3 @@ 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 ca7f432e79..13804959a6 100644 --- a/packages/studio/src/utils/studioUiPreferences.ts +++ b/packages/studio/src/utils/studioUiPreferences.ts @@ -16,7 +16,6 @@ export interface StudioUiPreferences { gridVisible?: boolean; gridSpacing?: number; snapToGrid?: boolean; - pinnedGroupsByElementType?: Record; } const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences"; @@ -89,15 +88,6 @@ 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 {};