feat(studio): flat inspector foundation + Text group (#2120)

## What

First PR in a 6-PR stack migrating Studio's right-panel property inspector from an always-expanded stacked-sections layout to a "flat" one-open-at-a-time accordion. This PR lays the foundation: the `STUDIO_FLAT_INSPECTOR_ENABLED` feature flag, the accordion primitives (`FlatRow`, `FlatSegmentedRow`, `FlatGroup`, `PinnedZoneDivider`), the flat identity header/footer, and the first migrated group — Text.

Stack: #2120 (this) → #2121 (Style) → #2122 (Layout+Motion) → #2123 (Media) → #2124 (Grade) → #2125 (Pinning + multi-field Text).

## Why

The legacy inspector renders every applicable section expanded at once, which gets unwieldy as an element accumulates properties across style/layout/motion/media/grade. The flat redesign shows one section at a time (plus pinned sections), matching a design handoff mock.

## How

- `FlatGroup` owns the one-open accordion state (`openGroupId`/`onToggleOpen`) and pin affordance (`onTogglePin`), styled per the design mock.
- `FlatTextSection` is the first migrated group and the reference implementation every later group's task followed for the `isOpen`/`onToggleOpen`/`onTogglePin`/`summary` wiring pattern.
- Includes a same-PR bugfix (found via live browser testing, not caught by any automated test): the Text `FlatGroup` was rendering unconditionally regardless of element type (empty for non-text elements), and the multi-field fallback doubled the "Text" heading. Fixed by gating on `isTextEditableSelection` and adding a `hideOwnHeading` prop to the legacy `TextSection` fallback.
- Entirely gated behind `STUDIO_FLAT_INSPECTOR_ENABLED` (default off) — the legacy panel is untouched and remains the default for all users.

## Test plan

- Every primitive and the Text group have dedicated Vitest suites using real DOM events (click/pointerdown) with exact assertions, not shallow snapshots.
- Manually verified in Studio via live browser testing against the design mock (this is what caught the bugfix above).
- Full monorepo test suite green; `oxlint`/`oxfmt` clean; this repo's `fallow` complexity/duplication gate passes.
- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (not applicable — internal Studio UI behind an off-by-default flag)
This commit is contained in:
Vance Ingalls
2026-07-14 15:47:14 -07:00
committed by GitHub
28 changed files with 2360 additions and 280 deletions
@@ -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 = (
<DesignPanelPromoteProvider
selection={domEditGroupSelections.length > 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}
@@ -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<typeof import("../../contexts/StudioContext")>(
"../../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 <TextSection> 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<typeof baseElement> = baseElement(),
) {
vi.resetModules();
vi.doMock("./manualEditingAvailability", async () => {
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
"./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(<PropertyPanel {...props} />);
});
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<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 collapsedRow = host.querySelector<HTMLButtonElement>(
'[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,
);
});
@@ -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 <PropertyPanelEmptyState multiSelectCount={multiSelectCount} />;
return (
<PropertyPanelEmptyState
flat={STUDIO_FLAT_INSPECTOR_ENABLED}
multiSelectCount={multiSelectCount}
multiSelectedElements={multiSelectedElements}
onGroupSelection={onGroupSelection}
onHideAllSelected={onHideAllSelected}
onClearSelection={onClearSelection}
/>
);
}
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 (
<PropertyPanelFlat
{...props}
element={element}
styles={styles}
sections={sections}
sourceLabel={sourceLabel}
gsapBorderRadius={gsapBorderRadius}
showEditableSections={showEditableSections}
selectedElementHidden={selectedElementHidden}
selectedElementId={selectedElementId}
clipboardCopied={clipboardCopied}
onCopyElementInfo={handleCopyElementInfo}
/>
);
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<div className="px-4 py-3">
@@ -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(<PropertyPanelEmptyState flat multiSelectCount={0} />);
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(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={elements}
onGroupSelection={onGroupSelection}
onHideAllSelected={onHideAllSelected}
onClearSelection={onClearSelection}
/>,
);
expect(host.textContent).toContain("2 elements selected");
expect(host.textContent).toContain("Mono Label");
expect(host.textContent).toContain("S2 Chart");
const group = host.querySelector<HTMLButtonElement>('[data-flat-multiselect-group="true"]');
act(() => group?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onGroupSelection).toHaveBeenCalledTimes(1);
const hideAll = host.querySelector<HTMLButtonElement>(
'[data-flat-multiselect-hide-all="true"]',
);
act(() => hideAll?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onHideAllSelected).toHaveBeenCalledTimes(1);
const clear = host.querySelector<HTMLButtonElement>('[data-flat-multiselect-clear="true"]');
act(() => clear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onClearSelection).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
});
@@ -1,6 +1,184 @@
import { Eye, Layers } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditingTypes";
function FlatEmptyState() {
return (
<div className="flex h-full flex-col items-center justify-center gap-2.5 px-8 py-10 text-center">
<span className="flex h-11 w-11 items-center justify-center rounded-xl border border-panel-border-input bg-panel-input text-panel-text-3">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
>
<path d="M4 3l6 14 2-6 6-2z" strokeLinejoin="round" />
</svg>
</span>
<div className="text-[13px] font-semibold text-panel-text-0">Nothing selected</div>
<div className="max-w-[250px] text-[11px] leading-[1.5] text-panel-text-3">
Click any element on the canvas to edit it, or drag to select several.
</div>
<div className="mt-2 flex w-full flex-col gap-1.5">
<span className="flex items-center justify-between rounded-lg border border-panel-border bg-panel-bg px-3 py-2">
<span className="flex items-center gap-2 text-[11px] text-panel-text-2">
<span className="text-panel-danger"></span>
Record a gesture
</span>
<span className="rounded border border-panel-border-input px-[5px] py-px font-mono text-[9px] text-panel-text-5">
R
</span>
</span>
<span className="flex items-center justify-between rounded-lg border border-panel-border bg-panel-bg px-3 py-2">
<span className="flex items-center gap-2 text-[11px] text-panel-text-2">
<span className="text-panel-accent"></span>
Describe a change to the agent
</span>
<span className="rounded border border-panel-border-input px-[5px] py-px font-mono text-[9px] text-panel-text-5">
K
</span>
</span>
</div>
</div>
);
}
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 (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex items-center gap-3 rounded-xl border border-panel-border bg-panel-surface p-3">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-panel-accent/10 text-panel-accent">
<Layers size={16} />
</span>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-panel-text-0">
{multiSelectCount} elements selected
</div>
<div className="mt-px font-mono text-[10px] text-panel-text-3">
shift-click to add or remove
</div>
</div>
<button
type="button"
data-flat-multiselect-clear="true"
aria-label="Clear selection"
onClick={onClearSelection}
className="flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center text-panel-text-3"
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
<div className="flex flex-col gap-1">
{multiSelectedElements.map((element) => {
const { glyph, className } = elementKindGlyph(element);
return (
<span
key={element.id ?? element.selector}
className="flex items-center gap-2 rounded-lg border border-panel-border bg-panel-bg px-2.5 py-[7px]"
>
<span
className={`flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded text-[9px] font-bold ${className}`}
>
{glyph}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
{element.label}
</span>
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
{element.id ? `#${element.id}` : element.selector}
</span>
</span>
);
})}
</div>
<div className="flex gap-2">
<button
type="button"
data-flat-multiselect-group="true"
onClick={onGroupSelection}
className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
>
<Layers size={13} />
Group selection
</button>
<button
type="button"
data-flat-multiselect-hide-all="true"
onClick={onHideAllSelected}
className="flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2"
>
<Eye size={13} />
Hide all
</button>
</div>
<span className="text-center text-[10px] text-panel-text-5">
Select a single element to edit its properties
</span>
</div>
);
}
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 ? (
<FlatMultiSelectState
multiSelectCount={multiSelectCount}
multiSelectedElements={multiSelectedElements}
onGroupSelection={onGroupSelection}
onHideAllSelected={onHideAllSelected}
onClearSelection={onClearSelection}
/>
) : (
<FlatEmptyState />
);
}
export function PropertyPanelEmptyState({ multiSelectCount }: { multiSelectCount: number }) {
return (
<div className="flex h-full flex-col bg-neutral-900">
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
@@ -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<typeof resolveEditingSections>;
/**
* 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 34). 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<string, string>;
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<string>("text");
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
const isTextEditable = isTextEditableSelection(element);
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<PropertyPanelFlatHeader
name={element.label}
meta={`${sourceLabel} · ${element.tagName}`}
elementKind={elementKind}
hidden={selectedElementHidden}
onToggleHidden={
selectedElementId && onToggleElementHidden
? () => void onToggleElementHidden(selectedElementId, !selectedElementHidden)
: undefined
}
copied={clipboardCopied}
onCopy={onCopyElementInfo}
onClear={onClearSelection}
onUngroup={onUngroup}
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
/>
<div className="flex-1 overflow-y-auto">
{isTextEditable && (
<FlatGroup
title="Text"
isOpen={openGroupId === "text" || pinnedGroupIds.includes("text")}
isPinned={pinnedGroupIds.includes("text")}
onToggleOpen={() => setOpenGroupId((current) => (current === "text" ? "" : "text"))}
onTogglePin={() =>
setPinnedGroupIds((current) =>
current.includes("text")
? current.filter((id) => id !== "text")
: [...current, "text"],
)
}
summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")}
>
<FlatTextSection
element={element}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onAddTextField={onAddTextField}
onRemoveTextField={onRemoveTextField}
/>
</FlatGroup>
)}
{sections.timing && (
<TimingSection
element={element}
animations={gsapAnimations}
onSetAttribute={onSetAttribute}
/>
)}
{sections.colorGrading && (
<ColorGradingSection
key={[
element.id ?? "",
element.hfId ?? "",
element.selector ?? "",
String(element.selectorIndex ?? ""),
].join("|")}
projectId={projectId}
element={element}
assets={assets}
previewIframeRef={previewIframeRef}
onImportAssets={onImportAssets}
onSetAttributeLive={onSetAttributeLive}
onApplyScope={onApplyColorGradingScope}
/>
)}
{sections.media && (
<MediaSection
projectDir={projectDir}
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>
)}
{showEditableSections && (
<StyleSections
projectId={projectId}
element={element}
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
/>
)}
</div>
<PropertyPanelFlatFooter
onAskAgent={onAskAgent}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
</div>
);
}
@@ -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<Parameters<typeof PropertyPanelFlatFooter>[0]> = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<PropertyPanelFlatFooter {...overrides} />);
});
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-flat-footer-record="true"]');
expect(recordButton?.title).toBe("Stop recording 2.4s");
act(() => root.unmount());
});
});
@@ -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 (
<div className="flex items-center justify-between border-t border-panel-hairline px-4 py-[11px]">
<button
type="button"
data-flat-footer-ask="true"
onClick={onAskAgent}
disabled={!onAskAgent}
className="flex items-center gap-[7px] text-[11px] font-medium text-panel-text-2 disabled:cursor-not-allowed"
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="currentColor"
className="text-panel-accent"
>
<path d="M8 1l1.4 4.6L14 7l-4.6 1.4L8 13l-1.4-4.6L2 7l4.6-1.4z" />
</svg>
Ask agent about this element
</button>
{onToggleRecording && (
<button
type="button"
data-flat-footer-record="true"
aria-label={recordTitle}
title={recordTitle}
onMouseDown={(e) => e.preventDefault()}
onClick={onToggleRecording}
className={recording ? "text-panel-danger animate-pulse" : "text-panel-danger"}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
{recording ? (
<rect x="2" y="2" width="10" height="10" rx="1.5" />
) : (
<circle cx="7" cy="7" r="6" />
)}
</svg>
</button>
)}
</div>
);
}
@@ -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<Parameters<typeof PropertyPanelFlatHeader>[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(<PropertyPanelFlatHeader {...props} />);
});
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<HTMLButtonElement>(
'[aria-label="Copy element info to clipboard"]',
);
const clear = host.querySelector<HTMLButtonElement>('[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();
});
});
@@ -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 (
<div className="flex items-center gap-2.5 border-b border-panel-hairline px-4 py-3">
<Icon
size={15}
data-flat-header-icon="true"
className={`flex-shrink-0 ${ICON_COLOR_BY_KIND[elementKind]}`}
/>
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate text-[13px] font-semibold text-panel-text-0">{name}</span>
<span className="truncate font-mono text-[10px] text-panel-text-4">{meta}</span>
</div>
<div className="flex flex-shrink-0 items-center gap-2.5 text-panel-text-3">
{showUngroup && (
<button type="button" aria-label="Ungroup" title="Ungroup (⌘⇧G)" onClick={onUngroup}>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<rect x="1.5" y="1.5" width="7" height="7" rx="1" />
<rect x="7.5" y="7.5" width="7" height="7" rx="1" />
</svg>
</button>
)}
{onToggleHidden && (
<button
type="button"
aria-label={visibilityLabel}
title={visibilityLabel}
onClick={onToggleHidden}
>
{hidden ? <EyeSlash size={13} weight="bold" /> : <Eye size={13} weight="bold" />}
</button>
)}
<button
type="button"
aria-label="Copy element info to clipboard"
title={copied ? "Copied!" : "Copy element info for any AI agent"}
onClick={onCopy}
className={copied ? "text-panel-accent" : undefined}
>
<ClipboardList size={13} />
</button>
<button type="button" aria-label="Clear selection" onClick={onClear}>
<X size={13} />
</button>
</div>
</div>
);
}
@@ -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);
});
});
@@ -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";
@@ -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(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={vi.fn()} />);
});
const trigger = host.querySelector<HTMLButtonElement>('[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());
});
});
@@ -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<HTMLButtonElement | null>(null);
@@ -349,6 +351,30 @@ export function ColorField({
}
};
if (flat) {
return (
<div className="flex min-h-[30px] items-center justify-between">
<span className="text-[11px] text-panel-text-2">{label}</span>
<button
type="button"
data-flat-color-trigger="true"
disabled={disabled}
aria-label={`Pick ${label.toLowerCase()} color`}
ref={buttonRef}
onClick={openPicker}
className="flex items-center gap-2 disabled:cursor-not-allowed"
>
<span
className="h-4 w-4 flex-shrink-0 rounded-[4px]"
style={{ backgroundColor: value || "transparent" }}
/>
<span className="font-mono text-[11px] text-panel-text-0">{value}</span>
</button>
{picker}
</div>
);
}
return (
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>{label}</span>
@@ -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(
<FlatRow label="Weight" value="400 · Regular" tier="default" onCommit={vi.fn()} />,
);
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(
<FlatRow
label="Letter spacing"
value="3.96px"
tier="explicitCustom"
onCommit={vi.fn()}
onReset={onReset}
/>,
);
const value = host.querySelector('[data-flat-row-value="true"]');
expect(value?.className).toContain("text-panel-accent");
const reset = host.querySelector<HTMLButtonElement>('[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(
<FlatRow label="Size" value="22px" tier="explicitDefault" onCommit={onCommit} />,
);
const input = host.querySelector<HTMLInputElement>("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(
<FlatSegmentedRow
label="Align"
options={[
{ key: "left", node: "L", active: false },
{ key: "right", node: "R", active: true },
]}
onChange={onChange}
/>,
);
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(
<FlatGroup
title="Text"
isOpen
isPinned={false}
onToggleOpen={onToggleOpen}
onTogglePin={onTogglePin}
>
<div data-testid="body">body</div>
</FlatGroup>,
);
expect(host.querySelector('[data-testid="body"]')).not.toBeNull();
const pin = host.querySelector<HTMLButtonElement>('[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(
<FlatGroup
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"]');
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(<PinnedZoneDivider />);
expect(host.textContent).toContain("one open below");
act(() => root.unmount());
});
});
@@ -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 (
<div className="group flex min-h-[30px] items-center justify-between gap-3">
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
<span className="flex min-w-0 flex-shrink-0 items-center gap-1.5">
<span
data-flat-row-value="true"
className={`min-w-0 border-b pb-px font-mono text-[11px] ${VALUE_TIER_VALUE_CLASS[tier]} ${
tier === "explicitCustom"
? "border-transparent group-hover:border-panel-accent/35"
: "border-transparent group-hover:border-panel-border-input"
}`}
>
<CommitField
value={value}
disabled={disabled}
liveCommit={liveCommit}
onCommit={onCommit}
/>
</span>
{suffix}
{tier === "explicitCustom" && onReset && (
<button
type="button"
data-flat-row-reset="true"
title="Remove — fall back to default"
onClick={onReset}
className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100"
>
<RotateCcw size={11} />
</button>
)}
{dropdown && (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M2 3l3 4 3-4z" />
</svg>
)}
</span>
</div>
);
}
/* ------------------------------------------------------------------ */
/* 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 (
<div className="flex min-h-[32px] items-center justify-between">
<span className="text-[11px] text-panel-text-3">{label}</span>
<span className="flex items-center gap-0.5">
{options.map((option, index) => (
<span key={option.key} className="flex items-center">
<button
type="button"
data-flat-segment="true"
disabled={disabled}
onClick={() => onChange(option.key)}
className={`px-1.5 py-1 text-[11px] transition-colors disabled:cursor-not-allowed ${
option.active
? "border-b-2 border-panel-accent text-panel-text-0"
: "border-b-2 border-transparent text-panel-text-4 hover:text-panel-text-2"
}`}
>
{option.node}
</button>
{spacerAfterIndex === index && <span className="w-3" aria-hidden="true" />}
</span>
))}
</span>
</div>
);
}
/* ------------------------------------------------------------------ */
/* 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 (
<button
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"
>
<span className="flex min-w-0 items-center gap-2">
<span className="text-[12px] font-medium text-panel-text-2">{title}</span>
{summary && (
<span className="min-w-0 truncate font-mono text-[9px] text-panel-text-4">
{summary}
</span>
)}
</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M4 2l4 4-4 4z" />
</svg>
</button>
);
}
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>
);
}
/* ------------------------------------------------------------------ */
/* PinnedZoneDivider */
/* ------------------------------------------------------------------ */
export function PinnedZoneDivider() {
return (
<div className="flex items-center gap-3 px-4 py-2">
<span className="h-px flex-1 bg-panel-hairline" aria-hidden="true" />
<span className="text-[8px] font-semibold uppercase tracking-[0.14em] text-panel-text-5">
one open below
</span>
<span className="h-px flex-1 bg-panel-hairline" aria-hidden="true" />
</div>
);
}
@@ -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<string, string>;
fontAssets: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
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 (
<>
<TextAreaField
flat
label="Content"
value={field.value}
onCommit={(next) => onSetText(next, field.key)}
/>
<FontFamilyField
flat
value={field.computedStyles["font-family"] || styles["font-family"] || "inherit"}
importedFonts={fontAssets}
onImportFonts={onImportFonts}
onCommit={(next) => onSetTextFieldStyle(field.key, "font-family", next)}
/>
<FlatRow
label="Size"
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
/>
<div className="flex min-h-[30px] items-center justify-between">
<span
className={
VALUE_TIER_LABEL_CLASS[resolveValueTier(field.inlineStyles["font-weight"], "400")]
}
style={{ fontSize: 11 }}
>
Weight
</span>
<label className="flex items-center gap-1.5">
<select
value={weight}
onChange={(e) => onSetTextFieldStyle(field.key, "font-weight", e.target.value)}
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none ${
VALUE_TIER_VALUE_CLASS[resolveValueTier(field.inlineStyles["font-weight"], "400")]
}`}
>
{(weightOptions.includes(weight) ? weightOptions : [weight, ...weightOptions]).map(
(option) => (
<option key={option} value={option}>
{WEIGHT_LABELS[option] ?? option}
</option>
),
)}
</select>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M2 3l3 4 3-4z" />
</svg>
</label>
</div>
<FlatRow
label="Letter spacing"
value={getTextStyleValue(field, styles, "letter-spacing", "0px")}
tier={resolveValueTier(field.inlineStyles["letter-spacing"], "0px")}
onCommit={(next) =>
onSetTextFieldStyle(
field.key,
"letter-spacing",
normalizeTextMetricValue("letter-spacing", next),
)
}
onReset={() => onSetTextFieldStyle(field.key, "letter-spacing", "")}
/>
<FlatRow
label="Line height"
value={getTextStyleValue(field, styles, "line-height", "normal")}
tier={resolveValueTier(field.inlineStyles["line-height"], "normal")}
onCommit={(next) =>
onSetTextFieldStyle(
field.key,
"line-height",
normalizeTextMetricValue("line-height", next),
)
}
onReset={() => onSetTextFieldStyle(field.key, "line-height", "")}
/>
<FlatSegmentedRow
label="Align"
options={ALIGN_OPTIONS.map((option) => ({
key: option.key,
node: option.node,
active: align === option.key || (option.key === "left" && align === "start"),
}))}
onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)}
/>
<FlatSegmentedRow
label="Case · Style"
options={[
...CASE_OPTIONS.map((option) => ({
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);
}
}}
/>
<ColorField
flat
label="Color"
value={getTextFieldColor(field, styles)}
onCommit={(next) => onSetTextFieldStyle(field.key, "color", next)}
/>
</>
);
}
export function FlatTextSection({
element,
styles,
fontAssets,
onImportFonts,
onSetText,
onSetTextFieldStyle,
onAddTextField,
onRemoveTextField,
}: {
element: DomEditSelection;
styles: Record<string, string>;
fontAssets: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | 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 (
<TextSection
element={element}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onAddTextField={onAddTextField}
onRemoveTextField={onRemoveTextField}
hideOwnHeading
/>
);
}
return (
<div className="space-y-1.5">
<FlatTextFieldEditor
field={activeField}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
/>
<button
type="button"
onClick={() => void onAddTextField(activeField.key)}
className="mt-0.5 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
>
<Plus size={10} />
Add text field
</button>
</div>
);
}
@@ -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(
<FontFamilyField flat value="JetBrains Mono" importedFonts={[]} onCommit={vi.fn()} />,
);
});
const trigger = host.querySelector<HTMLButtonElement>('[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());
});
});
@@ -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<ImportedFontAsset[]>;
onCommit: (nextValue: string) => void;
@@ -366,6 +368,130 @@ export function FontFamilyField({
setOpen(false);
};
const dropdown = open && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-neutral-700 bg-neutral-950 shadow-2xl">
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2 border-b border-neutral-800 p-2">
<input
ref={inputRef}
type="text"
value={query}
disabled={disabled}
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
onChange={(e) => 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 && (
<button
type="button"
disabled={disabled || loadingLocalFonts}
onClick={loadBrowserLocalFonts}
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-100 disabled:cursor-not-allowed disabled:text-neutral-700"
>
{loadingLocalFonts ? "..." : "Local"}
</button>
)}
<button
type="button"
disabled={disabled || importingFonts || !onImportFonts}
onClick={() => fontInputRef.current?.click()}
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-100 disabled:cursor-not-allowed disabled:text-neutral-700"
>
{importingFonts ? "..." : "Import"}
</button>
<input
ref={fontInputRef}
type="file"
accept=".ttf,.otf,.ttc,.woff,.woff2,.eot,font/*"
multiple
aria-label="Import local font files"
disabled={disabled || importingFonts || !onImportFonts}
className="hidden"
onChange={async (event) => {
await handleImportFonts(event.target.files);
event.target.value = "";
}}
/>
</div>
{fontNotice && (
<div className="border-b border-neutral-800 px-3 py-2 text-[10px] leading-4 text-neutral-500">
{fontNotice}
</div>
)}
<div className="max-h-64 overflow-y-auto p-1">
{filteredOptions.length === 0 ? (
<div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
) : (
filteredOptions.map((option) => (
<button
key={`${option.source}-${option.family}`}
type="button"
onClick={() => commitFamily(option)}
className={`flex w-full min-w-0 items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[11px] transition-colors ${
option.family === currentFamily
? "bg-studio-accent/15 text-neutral-50"
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-medium">{option.family}</span>
{renderAliasFor(option.family) && (
<span className="flex-shrink-0 text-[9px] text-neutral-500">
{renderAliasFor(option.family)}
</span>
)}
</span>
<span className="flex-shrink-0 text-[9px] uppercase tracking-[0.14em] text-neutral-600">
{option.source}
</span>
</button>
))
)}
</div>
</div>
);
if (flat) {
return (
<div ref={containerRef} className="relative flex min-h-[30px] items-center justify-between">
<span className="text-[11px] text-panel-text-2">Font</span>
<button
type="button"
data-flat-font-trigger="true"
disabled={disabled}
onClick={() => setOpen((next) => !next)}
className="flex items-center gap-1.5 disabled:cursor-not-allowed"
>
<span
className="max-w-[200px] truncate font-mono text-[11px] text-panel-text-0"
style={{ fontFamily: value }}
>
{currentFamily}
</span>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M2 3l3 4 3-4z" />
</svg>
</button>
{dropdown}
</div>
);
}
return (
<div ref={containerRef} className="relative grid min-w-0 gap-1.5">
<span className={LABEL}>Font family</span>
@@ -385,98 +511,7 @@ export function FontFamilyField({
Font
</span>
</button>
{open && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-neutral-700 bg-neutral-950 shadow-2xl">
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2 border-b border-neutral-800 p-2">
<input
ref={inputRef}
type="text"
value={query}
disabled={disabled}
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
onChange={(e) => 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 && (
<button
type="button"
disabled={disabled || loadingLocalFonts}
onClick={loadBrowserLocalFonts}
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-100 disabled:cursor-not-allowed disabled:text-neutral-700"
>
{loadingLocalFonts ? "..." : "Local"}
</button>
)}
<button
type="button"
disabled={disabled || importingFonts || !onImportFonts}
onClick={() => fontInputRef.current?.click()}
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-100 disabled:cursor-not-allowed disabled:text-neutral-700"
>
{importingFonts ? "..." : "Import"}
</button>
<input
ref={fontInputRef}
type="file"
accept=".ttf,.otf,.ttc,.woff,.woff2,.eot,font/*"
multiple
aria-label="Import local font files"
disabled={disabled || importingFonts || !onImportFonts}
className="hidden"
onChange={async (event) => {
await handleImportFonts(event.target.files);
event.target.value = "";
}}
/>
</div>
{fontNotice && (
<div className="border-b border-neutral-800 px-3 py-2 text-[10px] leading-4 text-neutral-500">
{fontNotice}
</div>
)}
<div className="max-h-64 overflow-y-auto p-1">
{filteredOptions.length === 0 ? (
<div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
) : (
filteredOptions.map((option) => (
<button
key={`${option.source}-${option.family}`}
type="button"
onClick={() => commitFamily(option)}
className={`flex w-full min-w-0 items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[11px] transition-colors ${
option.family === currentFamily
? "bg-studio-accent/15 text-neutral-50"
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-medium">{option.family}</span>
{renderAliasFor(option.family) && (
<span className="flex-shrink-0 text-[9px] text-neutral-500">
{renderAliasFor(option.family)}
</span>
)}
</span>
<span className="flex-shrink-0 text-[9px] uppercase tracking-[0.14em] text-neutral-600">
{option.source}
</span>
</button>
))
)}
</div>
</div>
)}
{dropdown}
</div>
);
}
@@ -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<HTMLIFrameElement | null>,
): 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");
}
@@ -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,
@@ -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> = {}): 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<DomEditSelection> = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const element = makeElement(overrides);
act(() => {
root.render(
<FlatTextSection
element={element}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={vi.fn()}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
});
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(
<FlatTextSection
element={element}
styles={{}}
fontAssets={[]}
onSetText={vi.fn()}
onSetTextFieldStyle={onSetTextFieldStyle}
onAddTextField={vi.fn()}
onRemoveTextField={vi.fn()}
/>,
);
});
const select = host.querySelector<HTMLSelectElement>("select");
if (!select) throw new Error("expected a weight <select>");
act(() => {
select.value = "700";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onSetTextFieldStyle).toHaveBeenCalledWith("field-0", "font-weight", "700");
act(() => root.unmount());
});
it("suppresses TextSection's own heading when falling back for a multi-field element", () => {
const { host, root } = renderSection({
textFields: [
makeElement().textFields[0],
{
key: "field-1",
label: "Text",
value: "SECOND FIELD",
tagName: "div",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "self",
},
],
});
// 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.
expect(host.querySelector('[data-panel-section="text"]')).toBeNull();
// The multi-field fallback's own content must still render.
expect(host.textContent).toContain("Text layers");
act(() => root.unmount());
});
});
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { Plus, Type } from "../../icons/SystemIcons";
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
import type { ImportedFontAsset } from "./fontAssets";
@@ -12,20 +12,20 @@ import { PromotableControl } from "./PromotableControl";
/* Text helpers (used only by text section components) */
/* ------------------------------------------------------------------ */
function formatTextFieldPreview(value: string): string {
export function formatTextFieldPreview(value: string): string {
const collapsed = value.trim().replace(/\s+/g, " ");
if (collapsed.length <= 56) return collapsed;
return `${collapsed.slice(0, 55)}`;
}
function getTextFieldColor(
export function getTextFieldColor(
field: { computedStyles: Record<string, string> },
inheritedStyles: Record<string, string>,
): string {
return field.computedStyles.color || inheritedStyles.color || "rgb(0, 0, 0)";
}
function getTextStyleValue(
export function getTextStyleValue(
field: { computedStyles: Record<string, string> },
inheritedStyles: Record<string, string>,
property: string,
@@ -35,7 +35,7 @@ function getTextStyleValue(
}
const ALL_WEIGHTS = ["100", "200", "300", "400", "500", "600", "700", "800", "900"];
const WEIGHT_LABELS: Record<string, string> = {
export const WEIGHT_LABELS: Record<string, string> = {
"100": "100 · Thin",
"200": "200 · Extra Light",
"300": "300 · Light",
@@ -47,7 +47,7 @@ const WEIGHT_LABELS: Record<string, string> = {
"900": "900 · Black",
};
function detectAvailableWeights(fontFamily: string): string[] {
export function detectAvailableWeights(fontFamily: string): string[] {
const fonts = document.fonts;
if (!fonts) return ALL_WEIGHTS;
const family = fontFamily.split(",")[0]?.trim().replace(/['"]/g, "");
@@ -59,17 +59,19 @@ function detectAvailableWeights(fontFamily: string): string[] {
return available.length > 0 ? available : ALL_WEIGHTS;
}
function TextAreaField({
export function TextAreaField({
label,
value,
disabled,
autoFocus,
flat,
onCommit,
}: {
label: string;
value: string;
disabled?: boolean;
autoFocus?: boolean;
flat?: boolean;
onCommit: (nextValue: string) => void;
}) {
const [draft, setDraft] = useState(value);
@@ -105,6 +107,38 @@ function TextAreaField({
}, 120);
};
const handleFocus = () => {
focusedRef.current = true;
};
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setDraft(e.target.value);
scheduleCommit(e.target.value);
};
const handleBlur = () => {
focusedRef.current = false;
commitDraft(draft);
};
if (flat) {
return (
<div className="border-l-2 border-panel-border-input py-0.5 pl-[10px]">
<div className="mb-[3px] text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
{label}
</div>
<textarea
ref={textareaRef}
value={draft}
disabled={disabled}
rows={2}
onFocus={handleFocus}
onChange={handleChange}
onBlur={handleBlur}
className="w-full resize-none bg-transparent font-mono text-[11px] leading-normal text-panel-text-0 outline-none disabled:cursor-not-allowed disabled:text-panel-text-4"
/>
</div>
);
}
return (
<label className="grid min-w-0 gap-1.5">
<span className={LABEL}>{label}</span>
@@ -114,17 +148,9 @@ function TextAreaField({
value={draft}
disabled={disabled}
rows={4}
onFocus={() => {
focusedRef.current = true;
}}
onChange={(e) => {
setDraft(e.target.value);
scheduleCommit(e.target.value);
}}
onBlur={() => {
focusedRef.current = false;
commitDraft(draft);
}}
onFocus={handleFocus}
onChange={handleChange}
onBlur={handleBlur}
className="w-full resize-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
/>
</div>
@@ -356,6 +382,7 @@ export function TextSection({
onSetTextFieldStyle,
onAddTextField,
onRemoveTextField,
hideOwnHeading = false,
}: {
element: DomEditSelection;
styles: Record<string, string>;
@@ -365,6 +392,11 @@ export function TextSection({
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
/** Skip TextSection's own "Text" Section heading/wrapper for callers (the
* flat inspector's multi-field fallback) that already render their own
* "Text" heading one level up, to avoid a doubled heading. Defaults to
* false so the legacy (non-flat) call site is unaffected. */
hideOwnHeading?: boolean;
}) {
const hasTextControls = isTextEditableSelection(element);
const [activeTextFieldKey, setActiveTextFieldKey] = useState<string | null>(
@@ -386,85 +418,93 @@ export function TextSection({
if (!activeField) return null;
if (textFields.length === 1) {
const content = (
<TextFieldEditor
field={activeField}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
showRemove={false}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onRemoveTextField={onRemoveTextField}
/>
);
if (hideOwnHeading) return content;
return (
<Section title="Text" icon={<Type size={15} />} defaultCollapsed>
<TextFieldEditor
field={activeField}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
showRemove={false}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onRemoveTextField={onRemoveTextField}
/>
{content}
</Section>
);
}
return (
<Section title="Text" icon={<Type size={15} />}>
<div className="space-y-4">
<div className="grid gap-1.5">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className={LABEL}>Text layers</span>
<button
type="button"
onClick={() => {
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
if (nextKey) setActiveTextFieldKey(nextKey);
});
}}
className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
>
<Plus size={12} className="flex-shrink-0" />
<span className="truncate">Add text</span>
</button>
</div>
<div className="grid gap-2">
{textFields.map((field, index) => {
const active = field.key === activeField.key;
return (
<button
key={field.key}
type="button"
onClick={() => setActiveTextFieldKey(field.key)}
className={`min-w-0 w-full rounded-xl border px-3 py-2 text-left transition-colors ${
active
? "border-studio-accent/50 bg-studio-accent/10"
: "border-neutral-800 bg-neutral-900/80 hover:border-neutral-700 hover:bg-neutral-900"
}`}
>
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span
className="h-4 w-4 flex-shrink-0 rounded border border-neutral-700 bg-neutral-950"
style={{ backgroundColor: getTextFieldColor(field, styles) }}
/>
<span className="min-w-0 truncate text-[11px] font-medium text-neutral-100">
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
</span>
</div>
<span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] text-neutral-500">
{field.tagName}
const content = (
<div className="space-y-4">
<div className="grid gap-1.5">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className={LABEL}>Text layers</span>
<button
type="button"
onClick={() => {
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
if (nextKey) setActiveTextFieldKey(nextKey);
});
}}
className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
>
<Plus size={12} className="flex-shrink-0" />
<span className="truncate">Add text</span>
</button>
</div>
<div className="grid gap-2">
{textFields.map((field, index) => {
const active = field.key === activeField.key;
return (
<button
key={field.key}
type="button"
onClick={() => setActiveTextFieldKey(field.key)}
className={`min-w-0 w-full rounded-xl border px-3 py-2 text-left transition-colors ${
active
? "border-studio-accent/50 bg-studio-accent/10"
: "border-neutral-800 bg-neutral-900/80 hover:border-neutral-700 hover:bg-neutral-900"
}`}
>
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span
className="h-4 w-4 flex-shrink-0 rounded border border-neutral-700 bg-neutral-950"
style={{ backgroundColor: getTextFieldColor(field, styles) }}
/>
<span className="min-w-0 truncate text-[11px] font-medium text-neutral-100">
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
</span>
</div>
</button>
);
})}
</div>
<span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] text-neutral-500">
{field.tagName}
</span>
</div>
</button>
);
})}
</div>
<TextFieldEditor
field={activeField}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
showRemove={true}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onRemoveTextField={onRemoveTextField}
/>
</div>
<TextFieldEditor
field={activeField}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
showRemove={true}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
onRemoveTextField={onRemoveTextField}
/>
</div>
);
if (hideOwnHeading) return content;
return (
<Section title="Text" icon={<Type size={15} />}>
{content}
</Section>
);
}
@@ -25,6 +25,9 @@ export interface PropertyPanelProps {
assets: string[];
element: DomEditSelection | null;
multiSelectCount?: number;
multiSelectedElements?: DomEditSelection[];
onGroupSelection?: () => void;
onHideAllSelected?: () => void;
copiedAgentPrompt: boolean;
onClearSelection: () => void;
onUngroup?: () => void;
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
resolveValueTier,
VALUE_TIER_LABEL_CLASS,
VALUE_TIER_VALUE_CLASS,
} from "./propertyPanelValueTier";
describe("resolveValueTier", () => {
it("is 'default' when there is no explicit declaration", () => {
expect(resolveValueTier(undefined, "400")).toBe("default");
expect(resolveValueTier("", "400")).toBe("default");
});
it("is 'explicitDefault' when the explicit value equals the default", () => {
expect(resolveValueTier("400", "400")).toBe("explicitDefault");
expect(resolveValueTier(" normal ", "normal")).toBe("explicitDefault");
});
it("is 'explicitCustom' when the explicit value differs from the default", () => {
expect(resolveValueTier("3.96px", "0px")).toBe("explicitCustom");
});
});
describe("value tier class maps", () => {
it("covers all three tiers for both label and value", () => {
for (const tier of ["default", "explicitDefault", "explicitCustom"] as const) {
expect(VALUE_TIER_LABEL_CLASS[tier]).toBeTruthy();
expect(VALUE_TIER_VALUE_CLASS[tier]).toBeTruthy();
}
expect(VALUE_TIER_VALUE_CLASS.explicitCustom).toBe("text-panel-accent");
});
});
@@ -0,0 +1,28 @@
/**
* The flat inspector's 3-state value coloring (design_handoff_studio_inspector,
* verified against Studio Panel Redesign.dc.html #10a): a property row is either
* unset (no explicit declaration), explicitly declared but equal to its default
* (no visual "set" signal), or explicitly declared and different from its default
* (mint value + emphasized label + reset affordance).
*/
export type PropertyValueTier = "default" | "explicitDefault" | "explicitCustom";
export function resolveValueTier(
explicitValue: string | undefined,
defaultValue: string,
): PropertyValueTier {
if (explicitValue == null || explicitValue.trim() === "") return "default";
return explicitValue.trim() === defaultValue.trim() ? "explicitDefault" : "explicitCustom";
}
export const VALUE_TIER_LABEL_CLASS: Record<PropertyValueTier, string> = {
default: "text-panel-text-3",
explicitDefault: "text-panel-text-2",
explicitCustom: "text-panel-text-0",
};
export const VALUE_TIER_VALUE_CLASS: Record<PropertyValueTier, string> = {
default: "text-panel-text-3",
explicitDefault: "text-panel-text-0",
explicitCustom: "text-panel-accent",
};
@@ -17,6 +17,8 @@ const studioPreset = {
hover: "#27272A",
border: "#1E1E1E",
"border-input": "#27272A",
hairline: "#1A1A1C",
"text-0": "#FAFAFA",
"text-1": "#E4E4E7",
"text-2": "#A1A1AA",
"text-3": "#71717A",
@@ -24,6 +26,8 @@ const studioPreset = {
"text-5": "#3F3F46",
accent: "#3CE6AC",
danger: "#EF4444",
media: "#00E3FF",
container: "#F5A623",
},
},
},