refactor(studio): scroll only the open group's body, keep headers fixed

Collapsed group headers render in fixed, non-scrolling document flow
above and below the open group; only the open group's own content
scrolls, in a dedicated region. Also fixes the flat inspector footer's
missing background.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-14 15:51:55 -07:00
co-authored by Claude Sonnet 5
parent f6fa9b4ec3
commit 528f2be242
6 changed files with 263 additions and 59 deletions
@@ -165,6 +165,22 @@ function inferredMotionElement() {
// A single "to" tween running from t=2 to t=5 (position 2, duration 3), with
// keyframes on "x" at 0/50/100% — enough to drive both FlatTimingRow's
// inference and the Layout "x" row's keyframe-seek gutter.
// Six-group fixture (fixed-headers + scrollable-open-section worked example):
// tagName "img" turns on both Media and Grade (resolveEditingSections), on top
// of baseElement()'s text-editable + style-editable + timing-eligible
// (data-start) defaults — yielding all six groups in a known order:
// [text, style, layout, motion, grade, media].
function sixGroupElement() {
return {
...baseElement(),
id: "six-group",
selector: "#six-group",
label: "Six Group",
tagName: "img",
dataAttributes: { start: "0", duration: "4" },
};
}
const INFERRED_TIMING_ANIMATION = {
id: "a1",
targetSelector: "#inferred-anim",
@@ -812,7 +828,7 @@ describe("PropertyPanel — pinning", () => {
expect(pinnedRow?.textContent).toContain("Pinned");
// The divider must appear after the pinned zone.
const container = host.querySelector(".flex-1.overflow-y-auto");
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"));
@@ -837,3 +853,95 @@ describe("PropertyPanel — pinning", () => {
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
// dedicated region between the two fixed header stacks. Worked example: 6
// groups [text, style, layout, motion, grade, media], motion open (index 3)
// -> text/style/layout render as fixed collapsed headers before it, motion
// renders as an open header + scrollable body, grade/media render as fixed
// collapsed headers after it — in exactly that DOM order, nothing sticky.
describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)", () => {
it(
"renders before-open headers, the open group (header + scrollable body), then after-open headers, in that exact order, with nothing sticky",
async () => {
const { host, root } = await renderPanel(true, sixGroupElement());
// sixGroupElement() opens Text by default; open Motion (index 3) to
// match the worked example.
openFlatGroup(host, "Motion");
expect(openGroupText(host)).toContain("Motion");
const body = host.querySelector('[data-flat-panel-body="true"]');
if (!body) throw new Error("expected the flat panel body container");
// Titles in DOM order: each child is either a collapsed header button
// (before/after the open group) or the open-group wrapper div.
const titles = Array.from(body.children).map((child) => {
if (child.matches('[data-flat-group-collapsed="true"]')) return child.textContent ?? "";
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).
const groupTitles = titles.filter((t): t is string => t !== null);
expect(groupTitles).toHaveLength(6);
expect(groupTitles[0]).toContain("Text");
expect(groupTitles[1]).toContain("Style");
expect(groupTitles[2]).toContain("Layout");
expect(groupTitles[3]).toContain("Motion");
expect(groupTitles[4]).toContain("Grade");
expect(groupTitles[5]).toContain("Media");
// The open group (Motion, index 3) is the one wrapped in
// data-flat-group-open, sitting between the before/after collapsed
// headers — and it must contain a dedicated scrollable body.
const openWrapper = host.querySelector('[data-flat-group-open="true"]');
if (!openWrapper) throw new Error("expected the open-group wrapper");
expect(openWrapper.textContent).toContain("Motion");
expect(openWrapper.querySelector(".overflow-y-auto")).not.toBeNull();
// Nothing anywhere in the panel body carries inline sticky positioning
// — the entire sticky-stacking mechanism is gone.
const stickyEls = Array.from(body.querySelectorAll<HTMLElement>("[style]")).filter(
(el) => el.style.position === "sticky",
);
expect(stickyEls).toHaveLength(0);
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
it(
"renders every group as a plain collapsed header with no scrollable middle region when nothing is open",
async () => {
const { host, root } = await renderPanel(true, sixGroupElement());
// Collapse the default-open Text group so openGroupId becomes "".
const collapseButton = host.querySelector<HTMLButtonElement>(
'[data-flat-group-open="true"] button[title="Collapse"]',
);
act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
const collapsedRows = Array.from(
host.querySelectorAll<HTMLButtonElement>('[data-flat-group-collapsed="true"]'),
);
expect(collapsedRows).toHaveLength(6);
const titlesInOrder = collapsedRows.map((el) => el.textContent ?? "");
expect(titlesInOrder[0]).toContain("Text");
expect(titlesInOrder[1]).toContain("Style");
expect(titlesInOrder[2]).toContain("Layout");
expect(titlesInOrder[3]).toContain("Motion");
expect(titlesInOrder[4]).toContain("Grade");
expect(titlesInOrder[5]).toContain("Media");
const body = host.querySelector('[data-flat-panel-body="true"]');
expect(body?.querySelector(".overflow-y-auto")).toBeNull();
for (const row of collapsedRows) {
expect(row.style.position).toBe("");
}
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
@@ -6,7 +6,7 @@ import type { PropertyPanelProps } from "./propertyPanelHelpers";
import { formatPxMetricValue } from "./propertyPanelHelpers";
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
import { FlatGroup, PinnedGroupRow, PinnedZoneDivider } from "./propertyPanelFlatPrimitives";
import { FlatGroupHeader, PinnedGroupRow, PinnedZoneDivider } from "./propertyPanelFlatPrimitives";
import { FlatTextSection } from "./propertyPanelFlatTextSection";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
@@ -456,6 +456,18 @@ 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
// flow and never move. Only the open group's own body content scrolls, in
// 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);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<PropertyPanelFlatHeader
@@ -474,7 +486,7 @@ export function PropertyPanelFlat({
onUngroup={onUngroup}
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
/>
<div className="flex-1 overflow-y-auto">
<div data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-hidden">
{pinned.map((g) => (
<PinnedGroupRow
key={g.id}
@@ -486,19 +498,42 @@ export function PropertyPanelFlat({
</PinnedGroupRow>
))}
{pinned.length > 0 && unpinned.length > 0 && <PinnedZoneDivider />}
{unpinned.map((g) => (
<FlatGroup
{beforeOpen.map((g) => (
<FlatGroupHeader
key={g.id}
title={g.title}
isOpen={openGroupId === g.id}
isOpen={false}
isPinned={false}
onToggleOpen={() => toggleOpen(g.id)}
onTogglePin={() => togglePin(g.id)}
summary={g.summary}
accessory={g.accessory}
>
{g.content}
</FlatGroup>
/>
))}
{openGroup && (
<div data-flat-group-open="true" className="flex min-h-0 flex-1 flex-col">
<FlatGroupHeader
title={openGroup.title}
isOpen
isPinned={false}
onToggleOpen={() => toggleOpen(openGroup.id)}
onTogglePin={() => togglePin(openGroup.id)}
accessory={openGroup.accessory}
/>
<div className="min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline px-4 py-3">
{openGroup.content}
</div>
</div>
)}
{afterOpen.map((g) => (
<FlatGroupHeader
key={g.id}
title={g.title}
isOpen={false}
isPinned={false}
onToggleOpen={() => toggleOpen(g.id)}
onTogglePin={() => togglePin(g.id)}
summary={g.summary}
/>
))}
</div>
<PropertyPanelFlatFooter
@@ -52,4 +52,33 @@ describe("PropertyPanelFlatFooter", () => {
expect(recordButton?.title).toBe("Stop recording 2.4s");
act(() => root.unmount());
});
// Plan 10 (sticky-footer-gap): the root must carry an opaque background —
// it previously had none at all, letting scrolled panel content show
// through. Regression coverage for the definite fix from the brief.
it("has an opaque bg-panel-bg background on its root element", () => {
const { host, root } = renderFooter({ onAskAgent: vi.fn() });
const footerRoot = host.firstElementChild as HTMLElement;
expect(footerRoot.className).toContain("bg-panel-bg");
act(() => root.unmount());
});
// Plan 11 (scrollable-open-section): the prior sticky-stacking mechanism —
// and the Plan 10 hairline-sealing hack it required at this exact boundary
// (an absolutely-positioned overlay patching a Chromium sticky-offset
// 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.
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;
expect(footerRoot.className).not.toContain("border-t");
expect(footerRoot.className).not.toContain("border-b");
expect(host.querySelector('[data-flat-footer-seal="true"]')).toBeNull();
act(() => root.unmount());
});
});
@@ -15,7 +15,13 @@ export function PropertyPanelFlatFooter({
: "Record gesture (R)";
return (
<div className="flex items-center justify-between border-t border-panel-hairline px-4 py-[11px]">
// 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).
<div className="flex items-center justify-between bg-panel-bg px-4 py-[11px]">
<button
type="button"
data-flat-footer-ask="true"
@@ -4,7 +4,7 @@ import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
FlatGroup,
FlatGroupHeader,
FlatRow,
FlatSegmentedRow,
FlatSelectRow,
@@ -109,49 +109,78 @@ describe("FlatSegmentedRow", () => {
});
});
describe("FlatGroup", () => {
it("renders the open header (name + pin + caret) and shows children", () => {
describe("FlatGroupHeader", () => {
it("renders the open header (name + pin + caret), with no sticky-related props required", () => {
const onToggleOpen = vi.fn();
const onTogglePin = vi.fn();
const { host, root } = renderInto(
<FlatGroup
<FlatGroupHeader
title="Text"
isOpen
isPinned={false}
onToggleOpen={onToggleOpen}
onTogglePin={onTogglePin}
>
<div data-testid="body">body</div>
</FlatGroup>,
/>,
);
expect(host.querySelector('[data-testid="body"]')).not.toBeNull();
expect(host.textContent).toContain("Text");
const pin = host.querySelector<HTMLButtonElement>('[data-flat-group-pin="true"]');
act(() => pin?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onTogglePin).toHaveBeenCalledTimes(1);
const collapse = host.querySelector<HTMLButtonElement>('button[title="Collapse"]');
act(() => collapse?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onToggleOpen).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("renders the collapsed row (name + summary + caret-right) and no children", () => {
it("renders the collapsed row (name + summary + caret-right) with no sticky positioning", () => {
const onToggleOpen = vi.fn();
const { host, root } = renderInto(
<FlatGroup
<FlatGroupHeader
title="Style"
isOpen={false}
isPinned={false}
onToggleOpen={onToggleOpen}
onTogglePin={vi.fn()}
summary="fill none · 100%"
>
<div data-testid="body">body</div>
</FlatGroup>,
/>,
);
expect(host.querySelector('[data-testid="body"]')).toBeNull();
expect(host.textContent).toContain("fill none · 100%");
const row = host.querySelector<HTMLButtonElement>('[data-flat-group-collapsed="true"]');
expect(row?.style.position).toBe("");
act(() => row?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onToggleOpen).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("renders no inline position styling in either state (collapsed headers never move)", () => {
const { host: collapsedHost, root: collapsedRoot } = renderInto(
<FlatGroupHeader
title="Layout"
isOpen={false}
isPinned={false}
onToggleOpen={vi.fn()}
onTogglePin={vi.fn()}
/>,
);
const row = collapsedHost.querySelector<HTMLButtonElement>(
'[data-flat-group-collapsed="true"]',
);
expect(row?.getAttribute("style")).toBeNull();
act(() => collapsedRoot.unmount());
const { host: openHost, root: openRoot } = renderInto(
<FlatGroupHeader
title="Motion"
isOpen
isPinned={false}
onToggleOpen={vi.fn()}
onTogglePin={vi.fn()}
/>,
);
expect(openHost.textContent).toContain("Motion");
expect(openHost.querySelector("[style]")).toBeNull();
act(() => openRoot.unmount());
});
});
describe("PinnedZoneDivider", () => {
@@ -133,10 +133,17 @@ export function FlatSegmentedRow({
}
/* ------------------------------------------------------------------ */
/* FlatGroup — one-open-at-a-time accordion group (controlled) */
/* FlatGroupHeader — one-open-at-a-time accordion group header */
/* (fixed-headers + scrollable-open-section layout, design_handoff */
/* scrollable-open-section): renders ONLY the header bar — collapsed */
/* button, or open-state title bar with pin/collapse controls. Never */
/* positioned (no sticky, no stacking offsets) — it always sits in */
/* normal document flow. The open group's body content is rendered by */
/* PropertyPanelFlat.tsx directly, in a dedicated scrollable region, */
/* not as children here. */
/* ------------------------------------------------------------------ */
export function FlatGroup({
export function FlatGroupHeader({
title,
isOpen,
isPinned,
@@ -144,7 +151,6 @@ export function FlatGroup({
onTogglePin,
accessory,
summary,
children,
}: {
title: string;
isOpen: boolean;
@@ -153,7 +159,6 @@ export function FlatGroup({
onTogglePin: () => void;
accessory?: ReactNode;
summary?: string;
children: ReactNode;
}) {
if (!isOpen) {
return (
@@ -161,7 +166,7 @@ export function FlatGroup({
type="button"
data-flat-group-collapsed="true"
onClick={onToggleOpen}
className="flex min-h-10 w-full items-center justify-between gap-2 border-b border-panel-hairline px-4 text-left"
className="flex min-h-10 w-full flex-shrink-0 items-center justify-between gap-2 border-b border-panel-hairline bg-panel-bg px-4 text-left"
>
<span className="flex min-w-0 items-center gap-2">
<span className="text-[12px] font-medium text-panel-text-2">{title}</span>
@@ -185,35 +190,27 @@ export function FlatGroup({
}
return (
<div className="border-b border-panel-hairline px-4 py-3" data-flat-group-open="true">
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[12px] font-semibold text-panel-text-0">{title}</span>
<span className="flex items-center gap-2.5 text-panel-text-5">
{accessory}
<button
type="button"
data-flat-group-pin="true"
title={isPinned ? "Unpin" : "Pin"}
onClick={onTogglePin}
className={isPinned ? "text-panel-accent" : "text-panel-text-5 hover:text-panel-text-3"}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M4 1h4v3.2l1.4 1.4V7H7v4L6 12l-1-1V7H2.6V5.6L4 4.2z" />
</svg>
</button>
<button
type="button"
onClick={onToggleOpen}
title="Collapse"
className="text-panel-text-3"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M2 4l4 4 4-4z" />
</svg>
</button>
</span>
</div>
{children}
<div className="flex min-h-10 flex-shrink-0 items-center justify-between bg-panel-bg px-4">
<span className="text-[12px] font-semibold text-panel-text-0">{title}</span>
<span className="flex items-center gap-2.5 text-panel-text-5">
{accessory}
<button
type="button"
data-flat-group-pin="true"
title={isPinned ? "Unpin" : "Pin"}
onClick={onTogglePin}
className={isPinned ? "text-panel-accent" : "text-panel-text-5 hover:text-panel-text-3"}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M4 1h4v3.2l1.4 1.4V7H7v4L6 12l-1-1V7H2.6V5.6L4 4.2z" />
</svg>
</button>
<button type="button" onClick={onToggleOpen} title="Collapse" className="text-panel-text-3">
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M2 4l4 4 4-4z" />
</svg>
</button>
</span>
</div>
);
}