From 9af7b6ee170007133b5f9bfee8fe871abcab3511 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 20:32:22 -0700 Subject: [PATCH 01/12] feat(studio): add flat-inspector tokens and STUDIO_FLAT_INSPECTOR_ENABLED flag --- .fallowrc.jsonc | 6 ++++++ .../editor/manualEditingAvailability.test.ts | 10 ++++++++++ .../src/components/editor/manualEditingAvailability.ts | 9 +++++++++ packages/studio/src/styles/tailwind-preset.shared.js | 4 ++++ 4 files changed, 29 insertions(+) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 32a6d5bc85..eba158acaf 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -260,6 +260,12 @@ "file": "packages/core/src/figma/manifest.ts", "exports": ["mediaDir", "typeDirPath", "isFigmaManifestRecord"], }, + // STUDIO_FLAT_INSPECTOR_ENABLED: exported for use by downstream studio + // inspector redesign tasks; consumed by components in later PRs. + { + "file": "packages/studio/src/components/editor/manualEditingAvailability.ts", + "exports": ["STUDIO_FLAT_INSPECTOR_ENABLED"], + }, ], "ignoreDependencies": [ // Runtime/dynamic deps not visible to static analysis: tsup `external`, diff --git a/packages/studio/src/components/editor/manualEditingAvailability.test.ts b/packages/studio/src/components/editor/manualEditingAvailability.test.ts index acd1f06940..2b36fe6406 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.test.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.test.ts @@ -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); + }); }); diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index b49a0523ca..5a9c802347 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -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"; diff --git a/packages/studio/src/styles/tailwind-preset.shared.js b/packages/studio/src/styles/tailwind-preset.shared.js index 35142881ea..0ba4a2ba6d 100644 --- a/packages/studio/src/styles/tailwind-preset.shared.js +++ b/packages/studio/src/styles/tailwind-preset.shared.js @@ -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", }, }, }, From 50ccc06cbad36c0382e791b23759225ba073e074 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 20:36:24 -0700 Subject: [PATCH 02/12] feat(studio): add 3-tier value/label color resolver for the flat inspector --- .../editor/propertyPanelValueTier.test.ts | 32 +++++++++++++++++++ .../editor/propertyPanelValueTier.ts | 28 ++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 packages/studio/src/components/editor/propertyPanelValueTier.test.ts create mode 100644 packages/studio/src/components/editor/propertyPanelValueTier.ts diff --git a/packages/studio/src/components/editor/propertyPanelValueTier.test.ts b/packages/studio/src/components/editor/propertyPanelValueTier.test.ts new file mode 100644 index 0000000000..80e4c22048 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelValueTier.test.ts @@ -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"); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelValueTier.ts b/packages/studio/src/components/editor/propertyPanelValueTier.ts new file mode 100644 index 0000000000..0e92f8b04d --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelValueTier.ts @@ -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 = { + default: "text-panel-text-3", + explicitDefault: "text-panel-text-2", + explicitCustom: "text-panel-text-0", +}; + +export const VALUE_TIER_VALUE_CLASS: Record = { + default: "text-panel-text-3", + explicitDefault: "text-panel-text-0", + explicitCustom: "text-panel-accent", +}; From 050a8f02338ca90fcd0449f1df0a6e1ed2d1450f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 20:44:29 -0700 Subject: [PATCH 03/12] feat(studio): add FlatRow and FlatSegmentedRow flat-inspector primitives --- .../propertyPanelFlatPrimitives.test.tsx | 101 +++++++++++++ .../editor/propertyPanelFlatPrimitives.tsx | 133 ++++++++++++++++++ .../editor/propertyPanelPrimitives.tsx | 2 +- 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx new file mode 100644 index 0000000000..30ad145c9d --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlatRow, FlatSegmentedRow } 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( + , + ); + 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( + , + ); + const value = host.querySelector('[data-flat-row-value="true"]'); + expect(value?.className).toContain("text-panel-accent"); + const reset = host.querySelector('[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( + , + ); + const input = host.querySelector("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( + , + ); + 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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx new file mode 100644 index 0000000000..5acd863ff6 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -0,0 +1,133 @@ +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 ( +
+ {label} + + + + + {suffix} + {tier === "explicitCustom" && onReset && ( + + )} + {dropdown && ( + + + + )} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* 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 ( +
+ {label} + + {options.map((option, index) => ( + + + {spacerAfterIndex === index && + ))} + +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx index ae12435607..d8a36eeef1 100644 --- a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx @@ -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, From e20edefc95066711025e0282854133a63a9eaef2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 20:57:11 -0700 Subject: [PATCH 04/12] feat(studio): add FlatGroup accordion and PinnedZoneDivider primitives --- .../propertyPanelFlatPrimitives.test.tsx | 60 ++++++++++- .../editor/propertyPanelFlatPrimitives.tsx | 102 ++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index 30ad145c9d..33b71d41e9 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -3,7 +3,12 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives"; +import { + FlatGroup, + FlatRow, + FlatSegmentedRow, + PinnedZoneDivider, +} from "./propertyPanelFlatPrimitives"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -99,3 +104,56 @@ describe("FlatSegmentedRow", () => { 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( + +
body
+
, + ); + expect(host.querySelector('[data-testid="body"]')).not.toBeNull(); + const pin = host.querySelector('[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( + +
body
+
, + ); + expect(host.querySelector('[data-testid="body"]')).toBeNull(); + expect(host.textContent).toContain("fill none · 100%"); + const row = host.querySelector('[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(); + expect(host.textContent).toContain("one open below"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 5acd863ff6..067ecb7a7b 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -131,3 +131,105 @@ export function FlatSegmentedRow({ ); } + +/* ------------------------------------------------------------------ */ +/* 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 ( + + ); + } + + return ( +
+
+ {title} + + {accessory} + + + +
+ {children} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* PinnedZoneDivider */ +/* ------------------------------------------------------------------ */ + +export function PinnedZoneDivider() { + return ( +
+
+ ); +} From 860f4ca057b562d9fb82514bf3a57094023a600a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 21:02:38 -0700 Subject: [PATCH 05/12] feat(studio): add flat identity header for the inspector --- .../editor/PropertyPanelFlatHeader.test.tsx | 79 ++++++++++++++++ .../editor/PropertyPanelFlatHeader.tsx | 89 +++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx create mode 100644 packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx diff --git a/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx b/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx new file mode 100644 index 0000000000..2d467db82b --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatHeader.test.tsx @@ -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[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(); + }); + 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( + '[aria-label="Copy element info to clipboard"]', + ); + const clear = host.querySelector('[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(); + }); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx b/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx new file mode 100644 index 0000000000..d56468eb58 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatHeader.tsx @@ -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 ( +
+ +
+ {name} + {meta} +
+
+ {showUngroup && ( + + )} + {onToggleHidden && ( + + )} + + +
+
+ ); +} From 74cd2d15b22035d9d189ba6acde93f7882c851c5 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 21:07:19 -0700 Subject: [PATCH 06/12] feat(studio): add flat footer (ask agent + record) for the inspector --- .../editor/PropertyPanelFlatFooter.test.tsx | 55 ++++++++++++++++++ .../editor/PropertyPanelFlatFooter.tsx | 58 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx create mode 100644 packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx diff --git a/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx b/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx new file mode 100644 index 0000000000..0ae07968f4 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatFooter.test.tsx @@ -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[0]> = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(); + }); + 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('[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('[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('[data-flat-footer-record="true"]'); + expect(recordButton?.title).toBe("Stop recording 2.4s"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx b/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx new file mode 100644 index 0000000000..90c898f860 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelFlatFooter.tsx @@ -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 ( +
+ + {onToggleRecording && ( + + )} +
+ ); +} From 547e947eb1c08fae9145f4df938d0a2b45b0814b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 21:25:46 -0700 Subject: [PATCH 07/12] feat(studio): add flat trigger variants for ColorField, FontFamilyField, TextAreaField --- .fallowrc.jsonc | 6 + .../editor/propertyPanelColor.test.tsx | 28 +++ .../components/editor/propertyPanelColor.tsx | 26 +++ .../editor/propertyPanelFont.test.tsx | 30 +++ .../components/editor/propertyPanelFont.tsx | 219 ++++++++++-------- .../editor/propertyPanelSections.tsx | 52 +++-- 6 files changed, 256 insertions(+), 105 deletions(-) create mode 100644 packages/studio/src/components/editor/propertyPanelColor.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelFont.test.tsx diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index eba158acaf..1447133942 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -266,6 +266,12 @@ "file": "packages/studio/src/components/editor/manualEditingAvailability.ts", "exports": ["STUDIO_FLAT_INSPECTOR_ENABLED"], }, + // TextAreaField: newly exported for FlatTextSection (flat inspector + // redesign, Task 8), which lands in a later commit on this branch. + { + "file": "packages/studio/src/components/editor/propertyPanelSections.tsx", + "exports": ["TextAreaField"], + }, ], "ignoreDependencies": [ // Runtime/dynamic deps not visible to static analysis: tsup `external`, diff --git a/packages/studio/src/components/editor/propertyPanelColor.test.tsx b/packages/studio/src/components/editor/propertyPanelColor.test.tsx new file mode 100644 index 0000000000..f49c22386c --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelColor.test.tsx @@ -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(); + }); + const trigger = host.querySelector('[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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelColor.tsx b/packages/studio/src/components/editor/propertyPanelColor.tsx index 962f0e9172..ea6dbb6008 100644 --- a/packages/studio/src/components/editor/propertyPanelColor.tsx +++ b/packages/studio/src/components/editor/propertyPanelColor.tsx @@ -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(null); @@ -349,6 +351,30 @@ export function ColorField({ } }; + if (flat) { + return ( +
+ {label} + + {picker} +
+ ); + } + return (
{label} diff --git a/packages/studio/src/components/editor/propertyPanelFont.test.tsx b/packages/studio/src/components/editor/propertyPanelFont.test.tsx new file mode 100644 index 0000000000..fe4d32a684 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFont.test.tsx @@ -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( + , + ); + }); + const trigger = host.querySelector('[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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFont.tsx b/packages/studio/src/components/editor/propertyPanelFont.tsx index a0fd50d7ab..47f9cce4c7 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.tsx @@ -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; onCommit: (nextValue: string) => void; @@ -366,6 +368,130 @@ export function FontFamilyField({ setOpen(false); }; + const dropdown = open && ( +
+
+ 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 && ( + + )} + + { + await handleImportFonts(event.target.files); + event.target.value = ""; + }} + /> +
+ {fontNotice && ( +
+ {fontNotice} +
+ )} +
+ {filteredOptions.length === 0 ? ( +
No fonts found.
+ ) : ( + filteredOptions.map((option) => ( + + )) + )} +
+
+ ); + + if (flat) { + return ( +
+ Font + + {dropdown} +
+ ); + } + return (
Font family @@ -385,98 +511,7 @@ export function FontFamilyField({ Font - - {open && ( -
-
- 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 && ( - - )} - - { - await handleImportFonts(event.target.files); - event.target.value = ""; - }} - /> -
- {fontNotice && ( -
- {fontNotice} -
- )} -
- {filteredOptions.length === 0 ? ( -
No fonts found.
- ) : ( - filteredOptions.map((option) => ( - - )) - )} -
-
- )} + {dropdown}
); } diff --git a/packages/studio/src/components/editor/propertyPanelSections.tsx b/packages/studio/src/components/editor/propertyPanelSections.tsx index 84ea41980e..a14ea65ae1 100644 --- a/packages/studio/src/components/editor/propertyPanelSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelSections.tsx @@ -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"; @@ -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) => { + setDraft(e.target.value); + scheduleCommit(e.target.value); + }; + const handleBlur = () => { + focusedRef.current = false; + commitDraft(draft); + }; + + if (flat) { + return ( +
+
+ {label} +
+