mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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 3–4). A text
|
||||
* element with the flag on will not show Layout/Motion controls; that
|
||||
* regression is scoped and acceptable for an unreleased, flag-gated feature.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function PropertyPanelFlat({
|
||||
element,
|
||||
styles,
|
||||
sections,
|
||||
sourceLabel,
|
||||
gsapAnimations = [],
|
||||
gsapBorderRadius,
|
||||
fontAssets = [],
|
||||
showEditableSections,
|
||||
selectedElementHidden,
|
||||
selectedElementId,
|
||||
clipboardCopied,
|
||||
onCopyElementInfo,
|
||||
projectId,
|
||||
projectDir,
|
||||
assets,
|
||||
previewIframeRef,
|
||||
onClearSelection,
|
||||
onUngroup,
|
||||
onSetStyle,
|
||||
onSetAttribute,
|
||||
onSetAttributeLive,
|
||||
onApplyColorGradingScope,
|
||||
onSetHtmlAttribute,
|
||||
onRemoveBackground,
|
||||
onSetText,
|
||||
onSetTextFieldStyle,
|
||||
onAddTextField,
|
||||
onRemoveTextField,
|
||||
onAskAgent,
|
||||
onToggleElementHidden,
|
||||
onImportAssets,
|
||||
onImportFonts,
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
}: Pick<
|
||||
PropertyPanelProps,
|
||||
| "projectId"
|
||||
| "projectDir"
|
||||
| "assets"
|
||||
| "previewIframeRef"
|
||||
| "onClearSelection"
|
||||
| "onUngroup"
|
||||
| "onSetStyle"
|
||||
| "onSetAttribute"
|
||||
| "onSetAttributeLive"
|
||||
| "onApplyColorGradingScope"
|
||||
| "onSetHtmlAttribute"
|
||||
| "onRemoveBackground"
|
||||
| "onSetText"
|
||||
| "onSetTextFieldStyle"
|
||||
| "onAddTextField"
|
||||
| "onRemoveTextField"
|
||||
| "onAskAgent"
|
||||
| "onToggleElementHidden"
|
||||
| "onImportAssets"
|
||||
| "onImportFonts"
|
||||
| "fontAssets"
|
||||
| "gsapAnimations"
|
||||
| "recordingState"
|
||||
| "recordingDuration"
|
||||
| "onToggleRecording"
|
||||
> & {
|
||||
element: DomEditSelection;
|
||||
styles: Record<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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user