From 197dec7a4a5303b7066f9662c1b47c46be784823 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 00:26:22 -0700 Subject: [PATCH 01/10] feat(studio): add FlatSlider primitive for the flat inspector --- .../propertyPanelFlatPrimitives.test.tsx | 69 ++++++++++++++++ .../editor/propertyPanelFlatPrimitives.tsx | 78 +++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index 33b71d41e9..8b2e2fef5a 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -7,6 +7,7 @@ import { FlatGroup, FlatRow, FlatSegmentedRow, + FlatSlider, PinnedZoneDivider, } from "./propertyPanelFlatPrimitives"; @@ -157,3 +158,71 @@ describe("PinnedZoneDivider", () => { act(() => root.unmount()); }); }); + +describe("FlatSlider", () => { + it("renders the default tier with a dim knob at the correct position", () => { + const { host, root } = renderInto( + , + ); + const knob = host.querySelector('[data-flat-slider-knob="true"]'); + expect(knob).not.toBeNull(); + expect(knob?.className).toContain("bg-panel-text-4"); + expect(knob?.style.left).toBe("0%"); + const value = host.querySelector('[data-flat-slider-value="true"]'); + expect(value?.className).toContain("text-panel-text-3"); + expect(value?.textContent).toBe("0px"); + act(() => root.unmount()); + }); + + it("renders the explicitCustom tier with a filled track and bright knob", () => { + const { host, root } = renderInto( + , + ); + const fill = host.querySelector('[data-flat-slider-fill="true"]'); + expect(fill?.style.width).toBe("100%"); + const knob = host.querySelector('[data-flat-slider-knob="true"]'); + expect(knob?.className).toContain("bg-white"); + act(() => root.unmount()); + }); + + it("commits a value on track click, proportional to click position", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 2, right: 200, bottom: 2 }), + }); + act(() => { + track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 })); + }); + expect(onCommit).toHaveBeenCalledWith(50); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 067ecb7a7b..a4b69df14d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -233,3 +233,81 @@ export function PinnedZoneDivider() { ); } + +/* ------------------------------------------------------------------ */ +/* FlatSlider — full-width label/track/value row */ +/* ------------------------------------------------------------------ */ + +export function FlatSlider({ + label, + value, + min, + max, + step = 1, + tier, + displayValue, + disabled, + onCommit, +}: { + label: string; + value: number; + min: number; + max: number; + step?: number; + tier: "default" | "explicitCustom"; + displayValue: string; + disabled?: boolean; + onCommit: (nextValue: number) => void; +}) { + const clampedPct = Math.max(0, Math.min(100, ((value - min) / Math.max(max - min, 1e-6)) * 100)); + + const commitFromClientX = (clientX: number, rect: DOMRect) => { + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / Math.max(rect.width, 1))); + const raw = min + ratio * (max - min); + const stepped = Math.round(raw / step) * step; + onCommit(Math.max(min, Math.min(max, stepped))); + }; + + return ( +
+ {label} +
{ + if (disabled) return; + commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect()); + }} + > + {tier === "explicitCustom" && ( +
+ )} +
+
+ + {displayValue} + +
+ ); +} From 5eeefdf85aee11cc4b3b7b0e329ac0ebec90eb0c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 00:32:22 -0700 Subject: [PATCH 02/10] feat(studio): add FlatSelectRow primitive for the flat inspector --- .../propertyPanelFlatPrimitives.test.tsx | 60 +++++++++++++++++ .../editor/propertyPanelFlatPrimitives.tsx | 64 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index 8b2e2fef5a..2eea86d44d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -7,6 +7,7 @@ import { FlatGroup, FlatRow, FlatSegmentedRow, + FlatSelectRow, FlatSlider, PinnedZoneDivider, } from "./propertyPanelFlatPrimitives"; @@ -226,3 +227,62 @@ describe("FlatSlider", () => { act(() => root.unmount()); }); }); + +describe("FlatSelectRow", () => { + it("renders the default tier with no reset button", () => { + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + expect(select?.value).toBe("normal"); + expect(host.querySelector('[data-flat-select-reset="true"]')).toBeNull(); + act(() => root.unmount()); + }); + + it("renders the explicitCustom tier with a reset button and fires onReset", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + expect(select?.className).toContain("text-panel-accent"); + const reset = host.querySelector('[data-flat-select-reset="true"]'); + act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("fires onChange when the select value changes", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + if (!select) throw new Error("expected a select"); + act(() => { + select.value = "hidden"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onChange).toHaveBeenCalledWith("hidden"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index a4b69df14d..6cc2217358 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -311,3 +311,67 @@ export function FlatSlider({
); } + +/* ------------------------------------------------------------------ */ +/* FlatSelectRow — label/value row backed by a native onChange(e.target.value)} + className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`} + > + {options.map((option) => ( + + ))} + + + + + + {tier === "explicitCustom" && onReset && ( + + )} + +
+ ); +} From 4a23ab46ad7265e5c51491d5081400bc57c7655b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 00:35:08 -0700 Subject: [PATCH 03/10] feat(studio): add stroke-summary format/parse helpers for the flat Style group --- .../propertyPanelFlatStyleHelpers.test.ts | 23 +++++++++++++++++++ .../editor/propertyPanelFlatStyleHelpers.ts | 12 ++++++++++ 2 files changed, 35 insertions(+) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.test.ts create mode 100644 packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.test.ts b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.test.ts new file mode 100644 index 0000000000..dff96a42f2 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers"; + +describe("formatStrokeSummary", () => { + it("formats width and style into one string", () => { + expect(formatStrokeSummary(1, "solid")).toBe("1px solid"); + expect(formatStrokeSummary(2.5, "dashed")).toBe("2.5px dashed"); + expect(formatStrokeSummary(0, "none")).toBe("0px none"); + }); +}); + +describe("parseStrokeSummary", () => { + it("parses a well-formed summary back into width and style", () => { + expect(parseStrokeSummary("1px solid")).toEqual({ widthPx: 1, style: "solid" }); + expect(parseStrokeSummary(" 2.5px dashed ")).toEqual({ widthPx: 2.5, style: "dashed" }); + }); + + it("returns null for unparseable input", () => { + expect(parseStrokeSummary("garbage")).toBeNull(); + expect(parseStrokeSummary("")).toBeNull(); + expect(parseStrokeSummary("1px")).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts new file mode 100644 index 0000000000..3f1db7ef73 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts @@ -0,0 +1,12 @@ +export function formatStrokeSummary(widthPx: number, style: string): string { + return `${widthPx}px ${style}`; +} + +export function parseStrokeSummary(text: string): { widthPx: number; style: string } | null { + const match = /^\s*(-?\d+(?:\.\d+)?)px\s+(\S+)\s*$/.exec(text); + if (!match) return null; + const widthPx = Number.parseFloat(match[1]); + const style = match[2]; + if (!Number.isFinite(widthPx) || !style) return null; + return { widthPx, style }; +} From b583cd1d328e8495e2204fc2c778a5c019e333de Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 00:43:05 -0700 Subject: [PATCH 04/10] feat(studio): add FlatStyleSection with the flat Fill sub-block --- .../propertyPanelFlatStyleSections.test.tsx | 124 +++++++++++++++ .../editor/propertyPanelFlatStyleSections.tsx | 148 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx new file mode 100644 index 0000000000..515878f7c5 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; +import type { DomEditSelection } from "./domEditing"; +import { buildDefaultGradientModel, serializeGradient } from "./gradientValue"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function makeElement(overrides: Partial = {}): DomEditSelection { + return { + element: document.createElement("div"), + id: "stat-card", + selector: ".stat-card", + label: "Stat Card", + tagName: "div", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 24, y: 120, width: 420, height: 260 }, + textContent: "", + dataAttributes: {}, + inlineStyles: { "background-color": "#0D0C09" }, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + } as DomEditSelection; +} + +function renderSection( + styles: Record = {}, + overrides: Partial = {}, +) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeElement(overrides); + const onSetStyle = vi.fn(); + const mergedStyles = { "background-color": "#0D0C09", "border-width": "0px", ...styles }; + act(() => { + root.render( + , + ); + }); + return { host, root, onSetStyle }; +} + +function clickSegment(host: HTMLElement, label: string) { + const segment = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find( + (el) => el.textContent === label, + ); + act(() => segment?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); +} + +describe("FlatStyleSection — Fill", () => { + it("renders the Fill segmented control defaulting to Solid, and a mint Color row when a color is set", () => { + const { host, root } = renderSection(); + expect(host.textContent).toContain("Fill"); + expect(host.textContent).toContain("Solid"); + const swatch = host.querySelector('[data-flat-color-trigger="true"]'); + expect(swatch).not.toBeNull(); + act(() => root.unmount()); + }); + + it("switches to the Gradient field when Gradient is selected", () => { + const { host, root } = renderSection({ + "background-image": "linear-gradient(90deg, #000, #fff)", + }); + const gradientSegment = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find( + (el) => el.textContent === "Gradient", + ); + expect(gradientSegment?.className).toContain("text-panel-text-0"); + act(() => root.unmount()); + }); + + it("clicking Gradient commits a serialized default gradient built from the current fill color", () => { + const { host, root, onSetStyle } = renderSection(); + clickSegment(host, "Gradient"); + const expectedGradient = serializeGradient(buildDefaultGradientModel("#0D0C09")); + expect(onSetStyle).toHaveBeenCalledWith("background-image", expectedGradient); + act(() => root.unmount()); + }); + + it("clicking Solid clears the background-image back to none", () => { + const { host, root, onSetStyle } = renderSection({ + "background-image": "linear-gradient(90deg, #000, #fff)", + }); + clickSegment(host, "Solid"); + expect(onSetStyle).toHaveBeenCalledWith("background-image", "none"); + act(() => root.unmount()); + }); + + it("clicking Image switches to the image-fill field without committing a style", () => { + const { host, root, onSetStyle } = renderSection(); + clickSegment(host, "Image"); + expect(host.textContent).toContain("Upload image"); + expect(onSetStyle).not.toHaveBeenCalledWith("background-image", expect.anything()); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx new file mode 100644 index 0000000000..5f0e7a6669 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -0,0 +1,148 @@ +// fallow-ignore-file code-duplication +import { useEffect, useState } from "react"; +import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; +import { buildDefaultGradientModel, serializeGradient } from "./gradientValue"; +import { extractBackgroundImageUrl } from "./propertyPanelHelpers"; +// oxlint-disable-next-line no-unused-vars +import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives"; +// oxlint-disable-next-line no-unused-vars +import { resolveValueTier } from "./propertyPanelValueTier"; +import { ColorField } from "./propertyPanelColor"; +import { GradientField, ImageFillField } from "./propertyPanelFill"; + +/* ------------------------------------------------------------------ */ +/* Flat Fill sub-block (design_handoff_studio_inspector, #11a) */ +/* ------------------------------------------------------------------ */ + +// fallow-ignore-next-line complexity +function FlatFillFields({ + projectId, + element, + styles, + assets, + onSetStyle, + onImportAssets, +}: { + projectId: string; + element: DomEditSelection; + styles: Record; + assets: string[]; + onSetStyle: (prop: string, value: string) => void | Promise; + onImportAssets?: (files: FileList) => Promise; +}) { + const styleEditingDisabled = !element.capabilities.canEditStyles; + const backgroundImage = styles["background-image"] ?? "none"; + const hasTextControls = isTextEditableSelection(element); + const fillMode = + backgroundImage && backgroundImage !== "none" + ? backgroundImage.includes("gradient") + ? "Gradient" + : "Image" + : "Solid"; + const [preferredFillMode, setPreferredFillMode] = useState(fillMode); + const imageUrl = extractBackgroundImageUrl(backgroundImage); + + useEffect(() => { + setPreferredFillMode(fillMode); + }, [fillMode, element.id, element.selector, backgroundImage]); + + const handleFillModeChange = (nextMode: string) => { + setPreferredFillMode(nextMode); + if (nextMode === "Solid") { + onSetStyle("background-image", "none"); + return; + } + if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) { + onSetStyle( + "background-image", + serializeGradient(buildDefaultGradientModel(styles["background-color"])), + ); + } + }; + + return ( + <> + + {preferredFillMode === "Solid" ? ( + onSetStyle("background-color", next)} + /> + ) : preferredFillMode === "Gradient" ? ( + onSetStyle("background-image", next)} + /> + ) : ( + onSetStyle("background-image", next)} + onImportAssets={onImportAssets} + /> + )} + {!hasTextControls && ( + onSetStyle("color", next)} + /> + )} + + ); +} + +export function FlatStyleSection({ + projectId, + element, + styles, + assets, + onSetStyle, + onImportAssets, + // oxlint-disable-next-line no-unused-vars + gsapBorderRadius, +}: { + projectId: string; + element: DomEditSelection; + styles: Record; + assets: string[]; + onSetStyle: (prop: string, value: string) => void | Promise; + onImportAssets?: (files: FileList) => Promise; + gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; +}) { + return ( +
+ +
+ ); +} From 4f1f00e89b5d56bb989534510570a5420cd5e9dd Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 01:06:23 -0700 Subject: [PATCH 05/10] feat(studio): add flat Stroke and Radius rows to the Style group --- .../propertyPanelFlatStyleSections.test.tsx | 94 +++++++++- .../editor/propertyPanelFlatStyleSections.tsx | 168 +++++++++++++++++- packages/studio/src/icons/SystemIcons.tsx | 2 + 3 files changed, 259 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx index 515878f7c5..81f46b46d5 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -47,6 +47,7 @@ function makeElement(overrides: Partial = {}): DomEditSelectio function renderSection( styles: Record = {}, overrides: Partial = {}, + gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = null, ) { const host = document.createElement("div"); document.body.append(host); @@ -62,7 +63,7 @@ function renderSection( styles={mergedStyles} assets={[]} onSetStyle={onSetStyle} - gsapBorderRadius={null} + gsapBorderRadius={gsapBorderRadius} />, ); }); @@ -122,3 +123,94 @@ describe("FlatStyleSection — Fill", () => { act(() => root.unmount()); }); }); + +function getFlatRowInput(host: HTMLElement, label: string): HTMLInputElement { + const rows = Array.from(host.querySelectorAll(".group")); + const row = rows.find((el) => el.querySelector("span")?.textContent === label); + const input = row?.querySelector("input"); + if (!input) throw new Error(`expected an input for row "${label}"`); + return input; +} + +async function commitFlatRowInput(host: HTMLElement, label: string, nextValue: string) { + const input = getFlatRowInput(host, label); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, nextValue); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + input.dispatchEvent(new Event("focusout", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +const STROKE_STYLES = { + "border-width": "1px", + "border-style": "solid", + "border-color": "rgba(255,255,255,.12)", +}; + +describe("FlatStyleSection — Stroke and Radius", () => { + it("renders the combined stroke row and commits width+style together on blur", () => { + const { host, root } = renderSection(STROKE_STYLES); + expect(host.textContent).toContain("Stroke"); + expect(getFlatRowInput(host, "Stroke").value).toBe("1px solid"); + act(() => root.unmount()); + }); + + it("commits the stroke row's new width and style together on blur", async () => { + const { host, root, onSetStyle } = renderSection(STROKE_STYLES); + await commitFlatRowInput(host, "Stroke", "2px dashed"); + expect(onSetStyle).toHaveBeenCalledWith("border-width", "2px"); + expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed"); + act(() => root.unmount()); + }); + + it("renders a single Radius value with a Linked indicator when corners are uniform", () => { + const { host, root } = renderSection({ "border-radius": "12px" }); + expect(host.textContent).toContain("Radius"); + expect(getFlatRowInput(host, "Radius").value).toBe("12px"); + expect(host.textContent).toContain("Linked"); + act(() => root.unmount()); + }); + + it("commits the radius row's new value to border-radius on blur when corners are uniform", async () => { + const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" }); + await commitFlatRowInput(host, "Radius", "20px"); + expect(onSetStyle).toHaveBeenCalledWith("border-radius", "20px"); + act(() => root.unmount()); + }); + + it("falls back to the legacy BorderRadiusEditor when corners are not uniform", () => { + const { host, root } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 }); + expect(host.textContent).not.toContain("Linked"); + act(() => root.unmount()); + }); + + it("commits a per-corner radius update through the legacy BorderRadiusEditor when unlinked", () => { + const { host, root, onSetStyle } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 }); + const trInput = Array.from(host.querySelectorAll("input")).find( + (el) => el.value === "12", + ); + if (!trInput) throw new Error("expected the TR corner input"); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(trInput, "18"); + trInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + act(() => { + trInput.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + expect(onSetStyle).toHaveBeenCalledWith("border-top-right-radius", "18px"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx index 5f0e7a6669..24a586e299 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -2,10 +2,20 @@ import { useEffect, useState } from "react"; import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; import { buildDefaultGradientModel, serializeGradient } from "./gradientValue"; -import { extractBackgroundImageUrl } from "./propertyPanelHelpers"; -// oxlint-disable-next-line no-unused-vars +import { Link as LinkIcon } from "../../icons/SystemIcons"; +import { BorderRadiusEditor } from "./BorderRadiusEditor"; +import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers"; +import { + buildStrokeStyleUpdates, + buildStrokeWidthStyleUpdates, + extractBackgroundImageUrl, + formatNumericValue, + formatPxMetricValue, + normalizePanelPxValue, + parseNumericValue, + parsePxMetricValue, +} from "./propertyPanelHelpers"; import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives"; -// oxlint-disable-next-line no-unused-vars import { resolveValueTier } from "./propertyPanelValueTier"; import { ColorField } from "./propertyPanelColor"; import { GradientField, ImageFillField } from "./propertyPanelFill"; @@ -115,6 +125,149 @@ function FlatFillFields({ ); } +/* ------------------------------------------------------------------ */ +/* Flat Stroke row — combined width+style+color */ +/* ------------------------------------------------------------------ */ + +// fallow-ignore-next-line complexity +function FlatStrokeRow({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const borderWidthValue = + parsePxMetricValue(styles["border-width"] ?? "") ?? + parsePxMetricValue(styles["border-top-width"] ?? "") ?? + 0; + const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none"; + const borderColorValue = + styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)"; + const summary = formatStrokeSummary(borderWidthValue, borderStyleValue); + const tier = resolveValueTier( + styles["border-width"] != null || styles["border-style"] != null ? summary : undefined, + formatStrokeSummary(0, "none"), + ); + + return ( + { + const parsed = parseStrokeSummary(next); + if (!parsed) return; + for (const [property, value] of buildStrokeWidthStyleUpdates( + formatPxMetricValue(parsed.widthPx), + parsed.style, + )) { + await onSetStyle(property, value); + } + for (const [property, value] of buildStrokeStyleUpdates( + parsed.style, + formatPxMetricValue(parsed.widthPx), + )) { + await onSetStyle(property, value); + } + }} + suffix={ + <> + + {borderColorValue} + + } + /> + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Radius row — uniform case; legacy fallback otherwise */ +/* ------------------------------------------------------------------ */ + +// fallow-ignore-next-line complexity +function FlatRadiusRow({ + styles, + gsapBorderRadius, + disabled, + onSetStyle, +}: { + styles: Record; + gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0; + const radiusTL = + gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue; + const radiusTR = + gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue; + const radiusBR = + gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue; + const radiusBL = + gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue; + const uniform = radiusTL === radiusTR && radiusTR === radiusBR && radiusBR === radiusBL; + + const commit = (corner: "all" | "tl" | "tr" | "br" | "bl", value: number) => { + const px = `${formatNumericValue(value)}px`; + if (corner === "all") { + void onSetStyle("border-radius", px); + return; + } + const prop = { + tl: "border-top-left-radius", + tr: "border-top-right-radius", + br: "border-bottom-right-radius", + bl: "border-bottom-left-radius", + }[corner]; + void onSetStyle(prop, px); + }; + + if (!uniform) { + return ( + + ); + } + + return ( + { + const parsed = parsePxMetricValue(next.endsWith("px") ? next : `${next}px`); + if (parsed == null) return; + const normalized = normalizePanelPxValue(`${parsed}px`, { + min: 0, + max: 400, + fallback: radiusTL, + }); + commit("all", normalized != null ? (parsePxMetricValue(normalized) ?? radiusTL) : radiusTL); + }} + suffix={ + + + Linked + + } + /> + ); +} + export function FlatStyleSection({ projectId, element, @@ -122,7 +275,6 @@ export function FlatStyleSection({ assets, onSetStyle, onImportAssets, - // oxlint-disable-next-line no-unused-vars gsapBorderRadius, }: { projectId: string; @@ -133,6 +285,7 @@ export function FlatStyleSection({ onImportAssets?: (files: FileList) => Promise; gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; }) { + const styleEditingDisabled = !element.capabilities.canEditStyles; return (
+ +
); } diff --git a/packages/studio/src/icons/SystemIcons.tsx b/packages/studio/src/icons/SystemIcons.tsx index 4f47d415b2..ca3f24d95d 100644 --- a/packages/studio/src/icons/SystemIcons.tsx +++ b/packages/studio/src/icons/SystemIcons.tsx @@ -21,6 +21,7 @@ import { ArrowClockwise, Gear, Scissors as PhScissors, + Link as PhLink, } from "@phosphor-icons/react"; import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react"; @@ -69,3 +70,4 @@ export const Camera = makeIcon(PhCamera); export const RotateCw = makeIcon(ArrowClockwise); export const Settings = makeIcon(Gear); export const Scissors = makeIcon(PhScissors); +export const Link = makeIcon(PhLink); From d01b9c2240a3201fca0cb3c6e3142f66d076bbb8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 01:17:07 -0700 Subject: [PATCH 06/10] feat(studio): add flat Shadow and Blend rows to the Style group --- .../propertyPanelFlatStyleSections.test.tsx | 63 +++++++++++++++++++ .../editor/propertyPanelFlatStyleSections.tsx | 56 ++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx index 81f46b46d5..6b47c7ddbd 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -214,3 +214,66 @@ describe("FlatStyleSection — Stroke and Radius", () => { act(() => root.unmount()); }); }); + +function getFlatSelectRow(host: HTMLElement, label: string) { + const rows = Array.from(host.querySelectorAll(".group")); + const row = rows.find((el) => el.querySelector("span")?.textContent === label); + if (!row) throw new Error(`expected a select row for "${label}"`); + const select = row.querySelector("select"); + if (!select) throw new Error(`expected a select for "${label}"`); + const resetButton = row.querySelector('[data-flat-select-reset="true"]'); + return { row, select, resetButton }; +} + +function changeFlatSelectRow(host: HTMLElement, label: string, nextValue: string) { + const { select } = getFlatSelectRow(host, label); + act(() => { + select.value = nextValue; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +describe("FlatStyleSection — Shadow and Blend", () => { + it("renders Shadow with the inferred preset and a reset when set, Blend with a plain select", () => { + const { host, root } = renderSection({ "box-shadow": "0 8px 24px rgba(0,0,0,.35)" }); + expect(host.textContent).toContain("Shadow"); + expect(host.textContent).toContain("Blend"); + const selects = host.querySelectorAll("select"); + expect(selects.length).toBeGreaterThanOrEqual(2); + act(() => root.unmount()); + }); + + it("commits a shadow preset change through onSetStyle", () => { + const { host, root, onSetStyle } = renderSection({}); + changeFlatSelectRow(host, "Shadow", "soft"); + expect(onSetStyle).toHaveBeenCalledWith("box-shadow", expect.any(String)); + act(() => root.unmount()); + }); + + it("commits a blend mode change through onSetStyle", () => { + const { host, root, onSetStyle } = renderSection({}); + changeFlatSelectRow(host, "Blend", "multiply"); + expect(onSetStyle).toHaveBeenCalledWith("mix-blend-mode", "multiply"); + act(() => root.unmount()); + }); + + it("resets the shadow preset back to none via the reset button", () => { + const { host, root, onSetStyle } = renderSection({ + "box-shadow": "0 12px 36px rgba(0, 0, 0, 0.28)", + }); + const { resetButton } = getFlatSelectRow(host, "Shadow"); + if (!resetButton) throw new Error("expected the shadow reset button"); + act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("box-shadow", "none"); + act(() => root.unmount()); + }); + + it("resets the blend mode back to normal via the reset button", () => { + const { host, root, onSetStyle } = renderSection({ "mix-blend-mode": "multiply" }); + const { resetButton } = getFlatSelectRow(host, "Blend"); + if (!resetButton) throw new Error("expected the blend reset button"); + act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("mix-blend-mode", "normal"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx index 24a586e299..b2f45548d0 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -6,16 +6,19 @@ import { Link as LinkIcon } from "../../icons/SystemIcons"; import { BorderRadiusEditor } from "./BorderRadiusEditor"; import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers"; import { + buildBoxShadowPresetValue, buildStrokeStyleUpdates, buildStrokeWidthStyleUpdates, extractBackgroundImageUrl, formatNumericValue, formatPxMetricValue, + inferBoxShadowPreset, normalizePanelPxValue, parseNumericValue, parsePxMetricValue, + type BoxShadowPreset, } from "./propertyPanelHelpers"; -import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives"; +import { FlatRow, FlatSegmentedRow, FlatSelectRow } from "./propertyPanelFlatPrimitives"; import { resolveValueTier } from "./propertyPanelValueTier"; import { ColorField } from "./propertyPanelColor"; import { GradientField, ImageFillField } from "./propertyPanelFill"; @@ -268,6 +271,52 @@ function FlatRadiusRow({ ); } +/* ------------------------------------------------------------------ */ +/* Flat Shadow + Blend rows */ +/* ------------------------------------------------------------------ */ + +function FlatShadowBlendRows({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]); + const blendValue = styles["mix-blend-mode"] || "normal"; + + return ( + <> + { + if (next === "custom") return; + void onSetStyle( + "box-shadow", + buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]), + ); + }} + onReset={() => void onSetStyle("box-shadow", "none")} + /> + void onSetStyle("mix-blend-mode", next)} + onReset={() => void onSetStyle("mix-blend-mode", "normal")} + /> + + ); +} + export function FlatStyleSection({ projectId, element, @@ -303,6 +352,11 @@ export function FlatStyleSection({ disabled={styleEditingDisabled} onSetStyle={onSetStyle} /> + ); } From 976afea1608222b494288b1418cd3bc3347e77ff Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 01:29:57 -0700 Subject: [PATCH 07/10] feat(studio): add flat Layer blur and Backdrop sliders to the Style group --- .../propertyPanelFlatStyleSections.test.tsx | 57 ++++++++++++++++++ .../editor/propertyPanelFlatStyleSections.tsx | 59 ++++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx index 6b47c7ddbd..936473245d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -277,3 +277,60 @@ describe("FlatStyleSection — Shadow and Blend", () => { act(() => root.unmount()); }); }); + +describe("FlatStyleSection — blur sliders", () => { + it("renders Layer blur and Backdrop sliders and commits through onSetStyle", () => { + const onSetStyle = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("Layer blur"); + expect(host.textContent).toContain("Backdrop"); + expect(host.textContent).toContain("4px"); + const track = host.querySelectorAll('[data-flat-slider-track="true"]')[0]; + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + }); + // filterBlurValue=4 -> max=Math.max(40, 4)=40; clientX=50 of width 100 -> ratio 0.5 -> 20px. + expect(onSetStyle).toHaveBeenCalledWith("filter", "blur(20px)"); + act(() => root.unmount()); + }); + + it("renders the Backdrop slider from backdrop-filter and commits a new blur value on track click", () => { + const { host, root, onSetStyle } = renderSection({ "backdrop-filter": "blur(6px)" }); + expect(host.textContent).toContain("Backdrop"); + expect(host.textContent).toContain("6px"); + const tracks = host.querySelectorAll('[data-flat-slider-track="true"]'); + const backdropTrack = tracks[tracks.length - 1]; + Object.defineProperty(backdropTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + backdropTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + }); + // backdropBlurValue=6 -> max=Math.max(60, 6)=60; clientX=50 of width 100 -> ratio 0.5 -> 30px. + expect(onSetStyle).toHaveBeenCalledWith("backdrop-filter", "blur(30px)"); + act(() => root.unmount()); + }); + + it("does not render a fill/knob highlight for a zero-value blur (default tier)", () => { + const { host, root } = renderSection({}); + expect(host.querySelectorAll('[data-flat-slider-fill="true"]')).toHaveLength(0); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx index b2f45548d0..ca9241cbac 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -12,13 +12,20 @@ import { extractBackgroundImageUrl, formatNumericValue, formatPxMetricValue, + getCssFilterFunctionPx, inferBoxShadowPreset, normalizePanelPxValue, parseNumericValue, parsePxMetricValue, + setCssFilterFunctionPx, type BoxShadowPreset, } from "./propertyPanelHelpers"; -import { FlatRow, FlatSegmentedRow, FlatSelectRow } from "./propertyPanelFlatPrimitives"; +import { + FlatRow, + FlatSegmentedRow, + FlatSelectRow, + FlatSlider, +} from "./propertyPanelFlatPrimitives"; import { resolveValueTier } from "./propertyPanelValueTier"; import { ColorField } from "./propertyPanelColor"; import { GradientField, ImageFillField } from "./propertyPanelFill"; @@ -317,6 +324,55 @@ function FlatShadowBlendRows({ ); } +/* ------------------------------------------------------------------ */ +/* Flat Layer blur + Backdrop sliders */ +/* ------------------------------------------------------------------ */ + +function FlatBlurSliders({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur"); + const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur"); + + return ( + <> + 0 ? "explicitCustom" : "default"} + displayValue={`${formatNumericValue(filterBlurValue)}px`} + disabled={disabled} + onCommit={(next) => + void onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next)) + } + /> + 0 ? "explicitCustom" : "default"} + displayValue={`${formatNumericValue(backdropBlurValue)}px`} + disabled={disabled} + onCommit={(next) => + void onSetStyle( + "backdrop-filter", + setCssFilterFunctionPx(styles["backdrop-filter"], "blur", next), + ) + } + /> + + ); +} + export function FlatStyleSection({ projectId, element, @@ -357,6 +413,7 @@ export function FlatStyleSection({ disabled={styleEditingDisabled} onSetStyle={onSetStyle} /> + ); } From fe9ad10c2f48c6b283cb9b3f8fcdbf610df9a828 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 01:39:21 -0700 Subject: [PATCH 08/10] feat(studio): add flat Overflow and Mask rows to the Style group --- .../propertyPanelFlatStyleSections.test.tsx | 132 ++++++++++++++++++ .../editor/propertyPanelFlatStyleSections.tsx | 109 +++++++++++++++ 2 files changed, 241 insertions(+) diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx index 936473245d..f4b12ecbf8 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -334,3 +334,135 @@ describe("FlatStyleSection — blur sliders", () => { act(() => root.unmount()); }); }); + +function getInsetSideInputOrNull(host: HTMLElement, label: "T" | "R" | "B" | "L") { + const span = Array.from(host.querySelectorAll("span")).find((el) => el.textContent === label); + return span?.parentElement?.querySelector("input") ?? null; +} + +function getInsetSideInput(host: HTMLElement, label: "T" | "R" | "B" | "L"): HTMLInputElement { + const input = getInsetSideInputOrNull(host, label); + if (!input) throw new Error(`expected an inset side input for "${label}"`); + return input; +} + +async function commitInsetSideInput( + host: HTMLElement, + label: "T" | "R" | "B" | "L", + nextValue: string, +) { + const input = getInsetSideInput(host, label); + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, nextValue); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + input.dispatchEvent(new Event("focusout", { bubbles: true })); + await Promise.resolve(); + }); +} + +describe("FlatStyleSection — Overflow and Mask", () => { + it("renders Overflow and Mask selects, and inset-side rows when the mask is an inset", () => { + const { host, root } = renderSection({ + overflow: "hidden", + "clip-path": "inset(8px round 4px)", + }); + expect(host.textContent).toContain("Overflow"); + expect(host.textContent).toContain("Mask"); + expect(host.textContent).toContain("hidden"); + act(() => root.unmount()); + }); + + it("commits an overflow change through onSetStyle", () => { + const onSetStyle = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const overflowSelect = Array.from(host.querySelectorAll("select")).find((s) => + Array.from(s.options).some((o) => o.value === "scroll"), + ); + if (!overflowSelect) throw new Error("expected the overflow select"); + act(() => { + overflowSelect.value = "hidden"; + overflowSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onSetStyle).toHaveBeenCalledWith("overflow", "hidden"); + act(() => root.unmount()); + }); + + it("resets overflow back to visible via the reset button", () => { + const { host, root, onSetStyle } = renderSection({ overflow: "scroll" }); + const { resetButton } = getFlatSelectRow(host, "Overflow"); + if (!resetButton) throw new Error("expected the overflow reset button"); + act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("overflow", "visible"); + act(() => root.unmount()); + }); + + it("commits a mask preset change through onSetStyle, building an inset() clip-path", () => { + const { host, root, onSetStyle } = renderSection({}); + changeFlatSelectRow(host, "Mask", "inset"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(0 round 0px)"); + act(() => root.unmount()); + }); + + it("resets the mask back to none via the reset button", () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "circle(50% at 50% 50%)" }); + const { resetButton } = getFlatSelectRow(host, "Mask"); + if (!resetButton) throw new Error("expected the mask reset button"); + act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "none"); + act(() => root.unmount()); + }); + + it("does not render inset-side rows when the mask is not an inset", () => { + const { host, root } = renderSection({ "clip-path": "circle(50% at 50% 50%)" }); + expect(getInsetSideInputOrNull(host, "T")).toBeNull(); + act(() => root.unmount()); + }); + + it("commits a T inset-side edit through onSetStyle, preserving the other sides and radius", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "T", "10"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(10px 8px 8px 8px round 4px)"); + act(() => root.unmount()); + }); + + it("commits an L inset-side edit through onSetStyle", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "L", "2"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 8px 2px round 4px)"); + act(() => root.unmount()); + }); + + it("commits an R inset-side edit through onSetStyle", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "R", "3"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 3px 8px 8px round 4px)"); + act(() => root.unmount()); + }); + + it("commits a B inset-side edit through onSetStyle", async () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + await commitInsetSideInput(host, "B", "5"); + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 5px 8px round 4px)"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx index ca9241cbac..8878409e1a 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -7,18 +7,24 @@ import { BorderRadiusEditor } from "./BorderRadiusEditor"; import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers"; import { buildBoxShadowPresetValue, + buildClipPathValue, + buildInsetClipPathSides, buildStrokeStyleUpdates, buildStrokeWidthStyleUpdates, extractBackgroundImageUrl, formatNumericValue, formatPxMetricValue, + getClipPathInsetPx, getCssFilterFunctionPx, inferBoxShadowPreset, + inferClipPathPreset, normalizePanelPxValue, + parseInsetClipPathSides, parseNumericValue, parsePxMetricValue, setCssFilterFunctionPx, type BoxShadowPreset, + type ClipPathInsetSides, } from "./propertyPanelHelpers"; import { FlatRow, @@ -26,6 +32,7 @@ import { FlatSelectRow, FlatSlider, } from "./propertyPanelFlatPrimitives"; +import { MetricField } from "./propertyPanelPrimitives"; import { resolveValueTier } from "./propertyPanelValueTier"; import { ColorField } from "./propertyPanelColor"; import { GradientField, ImageFillField } from "./propertyPanelFill"; @@ -373,6 +380,103 @@ function FlatBlurSliders({ ); } +/* ------------------------------------------------------------------ */ +/* Flat Overflow + Mask rows (+ inset sides) */ +/* ------------------------------------------------------------------ */ + +function FlatOverflowMaskRows({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0; + const clipPathValue = styles["clip-path"] || "none"; + const clipPathPreset = inferClipPathPreset(clipPathValue); + const parsedClipInsets = parseInsetClipPathSides(clipPathValue); + const clipInsetValue = getClipPathInsetPx(clipPathValue); + const clipInsetSides = parsedClipInsets ?? { + top: clipInsetValue, + right: clipInsetValue, + bottom: clipInsetValue, + left: clipInsetValue, + radius: radiusValue, + }; + const showClipInsetSides = clipPathPreset === "inset" || parsedClipInsets != null; + + const commitClipInsetSide = (side: keyof ClipPathInsetSides, nextValue: string) => { + const next = parsePxMetricValue(nextValue); + if (next == null) return; + const sides: ClipPathInsetSides = { + top: clipInsetSides.top, + right: clipInsetSides.right, + bottom: clipInsetSides.bottom, + left: clipInsetSides.left, + }; + sides[side] = next; + void onSetStyle("clip-path", buildInsetClipPathSides(sides, clipInsetSides.radius)); + }; + + return ( + <> + void onSetStyle("overflow", next)} + onReset={() => void onSetStyle("overflow", "visible")} + /> + { + void onSetStyle( + "clip-path", + buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue), + ); + }} + onReset={() => void onSetStyle("clip-path", "none")} + /> + {showClipInsetSides && ( +
+ commitClipInsetSide("top", next)} + /> + commitClipInsetSide("right", next)} + /> + commitClipInsetSide("bottom", next)} + /> + commitClipInsetSide("left", next)} + /> +
+ )} + + ); +} + export function FlatStyleSection({ projectId, element, @@ -414,6 +518,11 @@ export function FlatStyleSection({ onSetStyle={onSetStyle} /> + ); } From 22bd88c8c45378dcb4da5751f22638d41dc55fba Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 10:21:52 -0700 Subject: [PATCH 09/10] feat(studio): add flat Opacity slider, completing the Style group --- .../propertyPanelFlatStyleSections.test.tsx | 44 ++++++++++++++++++- .../editor/propertyPanelFlatStyleSections.tsx | 30 +++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx index f4b12ecbf8..90767fec4d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -316,7 +316,8 @@ describe("FlatStyleSection — blur sliders", () => { expect(host.textContent).toContain("Backdrop"); expect(host.textContent).toContain("6px"); const tracks = host.querySelectorAll('[data-flat-slider-track="true"]'); - const backdropTrack = tracks[tracks.length - 1]; + // Track order is Layer blur, Backdrop, Opacity — Backdrop is the second track. + const backdropTrack = tracks[1]; Object.defineProperty(backdropTrack, "getBoundingClientRect", { value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), }); @@ -330,7 +331,13 @@ describe("FlatStyleSection — blur sliders", () => { it("does not render a fill/knob highlight for a zero-value blur (default tier)", () => { const { host, root } = renderSection({}); - expect(host.querySelectorAll('[data-flat-slider-fill="true"]')).toHaveLength(0); + const tracks = host.querySelectorAll('[data-flat-slider-track="true"]'); + // Only the first two tracks are the blur sliders (Layer blur, Backdrop); Opacity + // (the third track) always renders a fill by design, so it's excluded here. + const blurTracks = Array.from(tracks).slice(0, 2); + for (const track of blurTracks) { + expect(track.querySelectorAll('[data-flat-slider-fill="true"]')).toHaveLength(0); + } act(() => root.unmount()); }); }); @@ -466,3 +473,36 @@ describe("FlatStyleSection — Overflow and Mask", () => { act(() => root.unmount()); }); }); + +describe("FlatStyleSection — Opacity", () => { + it("renders the Opacity slider at 100% by default and commits a change through onSetStyle", () => { + const onSetStyle = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + expect(host.textContent).toContain("Opacity"); + expect(host.textContent).toContain("100%"); + const tracks = host.querySelectorAll('[data-flat-slider-track="true"]'); + const opacityTrack = tracks[tracks.length - 1]; + Object.defineProperty(opacityTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + opacityTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + }); + expect(onSetStyle).toHaveBeenCalledWith("opacity", "0.5"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx index 8878409e1a..0819ae6603 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -477,6 +477,35 @@ function FlatOverflowMaskRows({ ); } +/* ------------------------------------------------------------------ */ +/* Flat Opacity slider */ +/* ------------------------------------------------------------------ */ + +function FlatOpacitySlider({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100); + + return ( + void onSetStyle("opacity", formatNumericValue(next / 100))} + /> + ); +} + export function FlatStyleSection({ projectId, element, @@ -523,6 +552,7 @@ export function FlatStyleSection({ disabled={styleEditingDisabled} onSetStyle={onSetStyle} /> + ); } From e02e0f52c2ac811b6050353c5fca0d8340d47245 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 10:32:13 -0700 Subject: [PATCH 10/10] feat(studio): wire the flat Style group into the one-open accordion --- .../components/editor/PropertyPanel.test.tsx | 61 ++++++++++++++++++- .../src/components/editor/PropertyPanel.tsx | 1 + .../components/editor/PropertyPanelFlat.tsx | 52 ++++++++++++---- 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 5c3845e591..d38f67b6dc 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -100,6 +100,20 @@ function multiFieldTextElement() { }; } +// Style-only fixture: no text fields (Text group must not render), but +// canEditStyles stays true (inherited from baseElement()) so the Style group +// is gated in. +function styleOnlyElement() { + return { + ...baseElement(), + id: "stat-card", + selector: ".stat-card", + label: "Stat Card", + textFields: [], + inlineStyles: { "background-color": "#0D0C09" }, + }; +} + async function renderPanel( flatEnabled: boolean, elementOverride: ReturnType = baseElement(), @@ -185,9 +199,19 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => { it( "renders no Text group at all for a non-text element (bug 1)", async () => { + // nonTextElement() inherits canEditStyles: true from baseElement(), so + // the Style group (Task 10) renders and opens by default here — the + // invariant under test is narrower than "no flat group at all": no + // group titled "Text" may appear, open or collapsed. 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(); + const openTitle = host.querySelector( + '[data-flat-group-open="true"] .text-panel-text-0', + )?.textContent; + const collapsedTitles = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'), + ).map((el) => el.textContent); + expect(openTitle).not.toBe("Text"); + expect(collapsedTitles).not.toContain("Text"); act(() => root.unmount()); }, RENDER_TIMEOUT_MS, @@ -209,3 +233,36 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => { RENDER_TIMEOUT_MS, ); }); + +describe("PropertyPanel — Style group (flag on)", () => { + it( + "renders the Style group for a style-editable, non-text element", + async () => { + const { host, root } = await renderPanel(true, styleOnlyElement()); + expect(host.textContent).toContain("Style"); + expect(host.textContent).toContain("Fill"); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); + + it( + "one-open accordion: opening Style closes Text", + async () => { + // baseElement() is text-editable and has capabilities.canEditStyles: + // true, so both the Text and Style groups render for it. + const { host, root } = await renderPanel(true); + const textGroup = () => host.querySelector('[data-flat-group-open="true"]'); + expect(textGroup()?.textContent).toContain("Text"); + const styleCollapsedRow = Array.from( + host.querySelectorAll('[data-flat-group-collapsed="true"]'), + ).find((el) => el.textContent?.includes("Style")); + if (!styleCollapsedRow) throw new Error("expected a collapsed Style row"); + act(() => styleCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(textGroup()?.textContent).not.toContain("Text"); + expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Style"); + act(() => root.unmount()); + }, + RENDER_TIMEOUT_MS, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index e0c544effd..574a8d7785 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -268,6 +268,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro return ( 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("text"); + // Lazy initializer: pick whichever group actually renders for this element + // (Text if text-editable, else Style if style-editable, else none open) so a + // style-only element doesn't start with everything collapsed. Only runs on + // mount — PropertyPanel.tsx keys by element identity so + // switching the selection re-mounts this component and re-derives the + // default instead of preserving stale state across unrelated elements. + const [openGroupId, setOpenGroupId] = useState(() => + isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "", + ); const [pinnedGroupIds, setPinnedGroupIds] = useState([]); const isTextEditable = isTextEditableSelection(element); const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other"; + const toggleOpen = (groupId: string) => + setOpenGroupId((current) => (current === groupId ? "" : groupId)); + const togglePin = (groupId: string) => + setPinnedGroupIds((current) => + current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId], + ); return (
@@ -136,14 +147,8 @@ export function PropertyPanelFlat({ 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"], - ) - } + onToggleOpen={() => toggleOpen("text")} + onTogglePin={() => togglePin("text")} summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")} > )} + {showEditableSections && ( + toggleOpen("style")} + onTogglePin={() => togglePin("style")} + summary={`fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`} + > + + + )} + {sections.timing && (