From 636e90ce0ea150afbfccc3bbd4f0a400b66c4fb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:53:37 +0000 Subject: [PATCH 1/2] feat(studio): fit promptwise's Timeline tab: no second header, a compact dock, BoardUI pill tabs Embedded in promptwise, Studio sat under the tab's own toolbar with a header of its own (logo, the folder's UUID, Capture, Inspector, Window, Export), and its three columns didn't fit the tab beside the chat (about 620px on a 1440px screen): the Design panel wrapped one word a line and its tab read "esign". - Embedded, Studio draws no header. The tab's toolbar carries Inspector and Export: it sends their presses as promptwise:command messages (only from the parent page, only those two), and Studio sends back their state as promptwise:state. - Below a 1000px dock the default layout is the preview over the timeline, with one side group (Design, Layers, Renders, Compositions, Assets) beside the preview, hidden until Inspector or a selection asks for it. - Tabs are BoardUI's gray pill tabs inside each panel's card, as in promptwise's own panels, instead of folder tabs on the shell. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LktT8pcsaiimfx2qqdN7Bf --- .../StudioHeader.embed.dom.test.tsx | 79 +++++++++++++++++++ .../studio/src/components/StudioHeader.tsx | 61 ++++++++------ .../src/components/dock/dockLayout.test.ts | 36 +++++++++ .../studio/src/components/dock/dockLayout.ts | 25 +++++- packages/studio/src/promptwise/theme.css | 19 ++++- .../src/promptwise/usePromptwiseToolbar.ts | 44 +++++++++++ 6 files changed, 235 insertions(+), 29 deletions(-) create mode 100644 packages/studio/src/components/StudioHeader.embed.dom.test.tsx create mode 100644 packages/studio/src/promptwise/usePromptwiseToolbar.ts diff --git a/packages/studio/src/components/StudioHeader.embed.dom.test.tsx b/packages/studio/src/components/StudioHeader.embed.dom.test.tsx new file mode 100644 index 00000000000..c6c30e47468 --- /dev/null +++ b/packages/studio/src/components/StudioHeader.embed.dom.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom + +/** Embedded in promptwise: no header; the Timeline tab's toolbar drives Inspector and Export by message. */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const layout = { rightCollapsed: true, setRightCollapsed: vi.fn(), setRightPanelTab: vi.fn() }; + +vi.mock("../promptwise/embed", () => ({ embeddedInPromptwise: true })); +vi.mock("../contexts/StudioContext", () => ({ + useStudioShellContext: () => ({ + projectId: "773d81ab-4ac0-4f9d-acd7-4cd8838312dd", + renderQueue: { isRendering: false, ffmpegMissing: false }, + }), +})); +vi.mock("../contexts/PanelLayoutContext", () => ({ usePanelLayoutContext: () => layout })); +vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +const { StudioHeader } = await import("./StudioHeader"); + +function command(name: string) { + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "promptwise:command", command: name }, + source: window.parent, + }), + ); + }); +} + +it("draws no header, runs the toolbar's presses and reports their state", () => { + const posted: unknown[] = []; + const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation((message) => { + posted.push(message); + }); + const onExport = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => + root.render( + , + ), + ); + + expect(host.innerHTML).toBe(""); + expect(posted).toEqual([{ type: "promptwise:state", inspectorOpen: false, rendering: false }]); + + command("inspector"); + expect(layout.setRightPanelTab).toHaveBeenLastCalledWith("design"); + expect(layout.setRightCollapsed).toHaveBeenLastCalledWith(false); + + command("export"); + expect(layout.setRightPanelTab).toHaveBeenLastCalledWith("renders"); + expect(onExport).toHaveBeenCalledOnce(); + + // Only the parent page may drive Studio, and only with the commands it knows. + window.dispatchEvent( + new MessageEvent("message", { data: { type: "promptwise:command", command: "export" } }), + ); + command("delete-everything"); + expect(onExport).toHaveBeenCalledOnce(); + + act(() => root.unmount()); + host.remove(); + postMessage.mockRestore(); +}); diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index 87a3a019592..4347e015756 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -7,6 +7,8 @@ import { Button, cn, Tooltip } from "./ui"; import { ButtonGroup, ButtonGroupItem } from "../boardui/components/base/buttons/button-group"; import { Dock } from "./dock/Dock"; import { PromptwiseMark } from "../promptwise/PromptwiseMark"; +import { embeddedInPromptwise } from "../promptwise/embed"; +import { usePromptwiseToolbar } from "../promptwise/usePromptwiseToolbar"; import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle"; interface StudioHeaderProps { @@ -55,6 +57,39 @@ export const StudioHeader = memo(function StudioHeader({ const isRendering = renderQueue.isRendering; const ffmpegMissing = renderQueue.ffmpegMissing; + const toggleInspector = () => { + if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) { + trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false }); + setRightPanelTab("design"); + setRightCollapsed(false); + return; + } + trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true }); + // Keep the current selection when collapsing the Inspector — closing + // the panel shouldn't deselect the element. + setRightCollapsed(true); + }; + + const exportVideo = () => { + if (isRendering) return; + setRightPanelTab("renders"); + setRightCollapsed(false); + // Without an encoder this render cannot finish, so the click + // delivers the user to the prompt that fixes it instead of + // queueing a job that exists only to fail. Disabling the button + // would leave them staring at a dead control with no route to + // the explanation. + if (ffmpegMissing) return; + onExport?.(); + }; + + usePromptwiseToolbar( + { inspectorOpen: inspectorButtonActive, rendering: isRendering }, + { inspector: toggleInspector, export: exportVideo }, + ); + // promptwise's Timeline tab has its own toolbar and tab name, so a second header would only repeat them. + if (embeddedInPromptwise) return null; + return (
@@ -104,18 +139,7 @@ export const StudioHeader = memo(function StudioHeader({ aria-pressed={inspectorButtonActive} selected={inspectorButtonActive} leadingIcon={RiLayoutRightLine} - onClick={() => { - if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) { - trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false }); - setRightPanelTab("design"); - setRightCollapsed(false); - return; - } - trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true }); - // Keep the current selection when collapsing the Inspector — closing - // the panel shouldn't deselect the element. - setRightCollapsed(true); - }} + onClick={toggleInspector} > Inspector @@ -137,18 +161,7 @@ export const StudioHeader = memo(function StudioHeader({ variant="primary" data-testid="header-export" disabled={isRendering} - onClick={() => { - if (isRendering) return; - setRightPanelTab("renders"); - setRightCollapsed(false); - // Without an encoder this render cannot finish, so the click - // delivers the user to the prompt that fixes it instead of - // queueing a job that exists only to fail. Disabling the button - // would leave them staring at a dead control with no route to - // the explanation. - if (ffmpegMissing) return; - onExport?.(); - }} + onClick={exportVideo} > {isRendering ? "Rendering…" : "Export"} diff --git a/packages/studio/src/components/dock/dockLayout.test.ts b/packages/studio/src/components/dock/dockLayout.test.ts index bddf62e8da8..ba2ef28924b 100644 --- a/packages/studio/src/components/dock/dockLayout.test.ts +++ b/packages/studio/src/components/dock/dockLayout.test.ts @@ -132,3 +132,39 @@ describe("applySideMinimums", () => { expect(timeline.group.minimumWidth).toBe(120); }); }); + +describe("buildEditLayout on a narrow dock", () => { + let api: DockviewApi; + beforeEach(() => { + ({ api } = mountBareDockview()); + }); + afterEach(() => { + api.dispose(); + document.body.innerHTML = ""; + }); + + it("puts the preview over the timeline and one hidden side group beside it, as in promptwise's tab", () => { + api.layout(640, 820); + buildEditLayout(api, 640); + const side = api.getPanel("design")!.group; + expect(side.panels.map((panel) => panel.id)).toEqual([ + "design", + "layers", + "renders", + "compositions", + "assets", + ]); + expect(side.api.isVisible).toBe(false); + expect(api.getPanel("preview")!.group.api.isVisible).toBe(true); + expect(api.getPanel("preview")!.group.width).toBe(640); + expect(api.getPanel("timeline")!.group.width).toBe(640); + expect(api.getPanel("code")).toBeUndefined(); + }); + + it("keeps the three columns on a wide one", () => { + api.layout(1200, 700); + buildEditLayout(api, 1200); + expect(api.getPanel("compositions")!.group).not.toBe(api.getPanel("design")!.group); + expect(api.getPanel("design")!.group.api.isVisible).toBe(true); + }); +}); diff --git a/packages/studio/src/components/dock/dockLayout.ts b/packages/studio/src/components/dock/dockLayout.ts index 94886d93aea..433c1d8cc76 100644 --- a/packages/studio/src/components/dock/dockLayout.ts +++ b/packages/studio/src/components/dock/dockLayout.ts @@ -10,6 +10,9 @@ const MIN_SIDE_W_FLOOR = 120; const DEFAULT_TIMELINE_H = 360; const DEFAULT_LEFT = ["compositions", "assets", "code", "catalog"] as const; const DEFAULT_RIGHT = ["design", "layers", "renders", "variables", "motionBlur"] as const; +/** Below this dock width (promptwise's Timeline tab beside its chat) two side columns would crush the preview. */ +const COMPACT_DOCK_W = 1000; +const COMPACT_SIDE = ["design", "layers", "renders", "compositions", "assets"] as const; /** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */ export function defaultSideWidths(viewportWidth: number) { @@ -99,12 +102,20 @@ function addSideColumn( return first; } -/** The default Edit layout: [library | preview | inspector] over a full-width timeline. */ +/** + * The default Edit layout: [library | preview | inspector] over a full-width timeline, or on a narrow + * dock the preview over the timeline with one side group beside the preview, shown when asked for + * (Inspector, a selection) since it would leave the preview too little room. + */ export function buildEditLayout( api: DockviewApi, viewportWidth: number, panels: readonly PanelId[] = PANEL_IDS, ) { + if (viewportWidth < COMPACT_DOCK_W) { + buildCompactLayout(api, viewportWidth, panels); + return; + } api.clear(); const widths = defaultSideWidths(viewportWidth); addRegisteredPanel(api, "preview"); @@ -116,3 +127,15 @@ export function buildEditLayout( if (right) api.getPanel(right)?.group.api.setSize({ width: widths.right }); api.getPanel("timeline")?.group.api.setSize({ height: DEFAULT_TIMELINE_H }); } + +function buildCompactLayout(api: DockviewApi, viewportWidth: number, panels: readonly PanelId[]) { + api.clear(); + addRegisteredPanel(api, "preview"); + addRegisteredPanel(api, "timeline", { referencePanel: "preview", direction: "below" }); + const side = addSideColumn(api, COMPACT_SIDE, panels, "right"); + applySideMinimums(api, viewportWidth); + api.getPanel("timeline")?.group.api.setSize({ height: DEFAULT_TIMELINE_H }); + const group = side ? api.getPanel(side)?.group : undefined; + group?.api.setSize({ width: Math.max(MIN_SIDE_W, defaultSideWidths(viewportWidth).right) }); + group?.api.setVisible(false); +} diff --git a/packages/studio/src/promptwise/theme.css b/packages/studio/src/promptwise/theme.css index ad40ce20a80..da8aca5cc43 100644 --- a/packages/studio/src/promptwise/theme.css +++ b/packages/studio/src/promptwise/theme.css @@ -24,7 +24,8 @@ --color-text-off: var(--color-text-tertiary); --panel-shell-bg: var(--color-background-secondary-default); - --panel-strip-bg: var(--color-background-secondary-default); + /* The tab strip sits inside each panel's card, as in promptwise's own panels. */ + --panel-strip-bg: var(--panel-card-bg); /* The promptwise orange. */ --color-accent: light-dark(var(--color-accent-600), var(--color-accent-500)); @@ -46,12 +47,22 @@ padding: 0 4px; } -/* BoardUI's card radius on the dock's panels and their tabs. */ +/* BoardUI's card radius on the dock's panels. */ .hf-dock .dv-groupview { border-radius: 16px; } -.hf-dock .dv-tab, +/* Tabs are BoardUI's gray pill tabs (promptwise's PillTab "gray"): the shown one on the tertiary background. */ +.hf-dock .dv-tabs-and-actions-container { + padding: 6px 6px 0; +} + +.hf-dock .dv-tab.dv-tab, +.hf-dock-tab-fill { + height: 28px; + border-radius: var(--radius-md); +} + .hf-dock-tab-fill { - border-radius: 10px 10px 0 0; + background-color: var(--color-background-tertiary-default); } diff --git a/packages/studio/src/promptwise/usePromptwiseToolbar.ts b/packages/studio/src/promptwise/usePromptwiseToolbar.ts new file mode 100644 index 00000000000..fcc5e0bafa3 --- /dev/null +++ b/packages/studio/src/promptwise/usePromptwiseToolbar.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef } from "react"; +import { embeddedInPromptwise } from "./embed"; + +export type PromptwiseCommand = "inspector" | "export"; + +export interface PromptwiseToolbarState { + inspectorOpen: boolean; + rendering: boolean; +} + +function isCommand(value: unknown): value is PromptwiseCommand { + return value === "inspector" || value === "export"; +} + +/** + * Embedded in promptwise, Studio draws no header: the Timeline tab's toolbar carries Inspector and + * Export. It sends their presses as promptwise:command messages, and Studio sends back their state. + */ +export function usePromptwiseToolbar( + state: PromptwiseToolbarState, + commands: Record void>, +) { + const latest = useRef(commands); + latest.current = commands; + + useEffect(() => { + if (!embeddedInPromptwise) return; + const onMessage = (event: MessageEvent) => { + if (event.source !== window.parent) return; + const data: unknown = event.data; + if (typeof data !== "object" || data === null) return; + const { type, command } = data as { type?: unknown; command?: unknown }; + if (type === "promptwise:command" && isCommand(command)) latest.current[command](); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + const { inspectorOpen, rendering } = state; + useEffect(() => { + if (!embeddedInPromptwise) return; + window.parent.postMessage({ type: "promptwise:state", inspectorOpen, rendering }, "*"); + }, [inspectorOpen, rendering]); +} From fb6fa91e92994bb2af9a028c887dd32549943409 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:34:40 +0000 Subject: [PATCH 2/2] feat(studio): polish promptwise's Timeline tab: opening frame, lighter stage, agent first - In promptwise a film opens a second in (halfway for one under two seconds), where its first shot has arrived, instead of on its empty first frame; a time in the link still wins. - The preview's overlay buttons wear BoardUI's light surface with a hairline instead of black glass, and the stage a hairline and a medium shadow instead of a heavy black one. - The compact dock gives the timeline 260px (three tracks), so the preview gets the rest, and the Shift+drag hint shows only while the pointer is over the timeline. - The empty Design panel leads with "Ask the agent", its one primary button, above "Record a gesture": in promptwise most changes go through the agent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LktT8pcsaiimfx2qqdN7Bf --- .../src/components/dock/dockLayout.test.ts | 1 + .../studio/src/components/dock/dockLayout.ts | 4 +- ...PropertyPanelEmptyState.embed.dom.test.tsx | 37 ++++++++++++ .../editor/PropertyPanelEmptyState.test.tsx | 2 +- .../editor/PropertyPanelEmptyState.tsx | 22 ++++---- .../src/components/editor/SnapToolbar.tsx | 22 +++----- .../src/components/nle/NLEPreview.test.ts | 6 +- .../studio/src/components/nle/NLEPreview.tsx | 2 +- .../useStudioUrlState.opening.dom.test.tsx | 56 +++++++++++++++++++ .../studio/src/hooks/useStudioUrlState.ts | 24 ++++++-- .../components/TimelineShortcutHint.tsx | 2 +- packages/studio/src/promptwise/embed.ts | 5 ++ packages/studio/src/promptwise/theme.css | 10 ++++ 13 files changed, 157 insertions(+), 36 deletions(-) create mode 100644 packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx create mode 100644 packages/studio/src/hooks/useStudioUrlState.opening.dom.test.tsx diff --git a/packages/studio/src/components/dock/dockLayout.test.ts b/packages/studio/src/components/dock/dockLayout.test.ts index ba2ef28924b..40c3a1271c2 100644 --- a/packages/studio/src/components/dock/dockLayout.test.ts +++ b/packages/studio/src/components/dock/dockLayout.test.ts @@ -158,6 +158,7 @@ describe("buildEditLayout on a narrow dock", () => { expect(api.getPanel("preview")!.group.api.isVisible).toBe(true); expect(api.getPanel("preview")!.group.width).toBe(640); expect(api.getPanel("timeline")!.group.width).toBe(640); + expect(api.getPanel("timeline")!.group.height).toBe(260); expect(api.getPanel("code")).toBeUndefined(); }); diff --git a/packages/studio/src/components/dock/dockLayout.ts b/packages/studio/src/components/dock/dockLayout.ts index 433c1d8cc76..acd9f8c1a35 100644 --- a/packages/studio/src/components/dock/dockLayout.ts +++ b/packages/studio/src/components/dock/dockLayout.ts @@ -13,6 +13,8 @@ const DEFAULT_RIGHT = ["design", "layers", "renders", "variables", "motionBlur"] /** Below this dock width (promptwise's Timeline tab beside its chat) two side columns would crush the preview. */ const COMPACT_DOCK_W = 1000; const COMPACT_SIDE = ["design", "layers", "renders", "compositions", "assets"] as const; +/** Room for three tracks under the toolbar and ruler; the preview gets the rest. */ +const COMPACT_TIMELINE_H = 260; /** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */ export function defaultSideWidths(viewportWidth: number) { @@ -134,7 +136,7 @@ function buildCompactLayout(api: DockviewApi, viewportWidth: number, panels: rea addRegisteredPanel(api, "timeline", { referencePanel: "preview", direction: "below" }); const side = addSideColumn(api, COMPACT_SIDE, panels, "right"); applySideMinimums(api, viewportWidth); - api.getPanel("timeline")?.group.api.setSize({ height: DEFAULT_TIMELINE_H }); + api.getPanel("timeline")?.group.api.setSize({ height: COMPACT_TIMELINE_H }); const group = side ? api.getPanel(side)?.group : undefined; group?.api.setSize({ width: Math.max(MIN_SIDE_W, defaultSideWidths(viewportWidth).right) }); group?.api.setVisible(false); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx new file mode 100644 index 00000000000..2b21a6f6328 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom + +/** In promptwise most changes go through the agent, so its button leads the empty Design panel. */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("../../promptwise/embed", async (original) => ({ + ...(await original()), + embeddedInPromptwise: true, +})); + +const { PropertyPanelEmptyState } = await import("./PropertyPanelEmptyState"); + +it("puts the agent first, as the panel's one primary button, and asks promptwise to open its chat", () => { + const posted: unknown[] = []; + const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation((message) => { + posted.push(message); + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => root.render()); + + const text = host.textContent ?? ""; + expect(text.indexOf("Ask the agent")).toBeLessThan(text.indexOf("Record a gesture")); + const agent = host.querySelector("button")!; + expect(agent.textContent).toContain("Ask the agent"); + act(() => agent.click()); + expect(posted).toEqual([{ type: "promptwise:ask-agent" }]); + + act(() => root.unmount()); + host.remove(); + postMessage.mockRestore(); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx index 2848c1ee0e3..1578a77bb71 100644 --- a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx @@ -28,7 +28,7 @@ describe("PropertyPanelEmptyState — flat empty", () => { expect(host.textContent).toContain("Nothing selected"); expect(host.textContent).toContain("Record a gesture"); // The agent chip only shows inside promptwise, whose chat it opens. - expect(host.textContent).not.toContain("Describe a change to the agent"); + expect(host.textContent).not.toContain("Ask the agent"); act(() => root.unmount()); }); }); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx index 067d9068210..8385c190baa 100644 --- a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx @@ -3,6 +3,7 @@ import type { DomEditSelection } from "./domEditingTypes"; import { canHideSelections } from "../../utils/timelineInspector"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { askPromptwiseAgent, embeddedInPromptwise } from "../../promptwise/embed"; +import { Button } from "../ui"; function FlatEmptyState() { return ( @@ -24,6 +25,17 @@ function FlatEmptyState() { Click any element on the canvas to edit it, or drag to select several.
+ {/* In promptwise the agent is the way most changes are made, so it comes first. */} + {embeddedInPromptwise && ( + + )} ● @@ -33,16 +45,6 @@ function FlatEmptyState() { R - {embeddedInPromptwise && ( - - )}
); diff --git a/packages/studio/src/components/editor/SnapToolbar.tsx b/packages/studio/src/components/editor/SnapToolbar.tsx index 8d5c72a2d55..63637d484b5 100644 --- a/packages/studio/src/components/editor/SnapToolbar.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.tsx @@ -5,6 +5,10 @@ import { usePlayerStore } from "../../player/store/playerStore"; import { usePreviewOverlayContext } from "./PreviewOverlayProvider"; import { ownsPlainKeys } from "../../utils/typingTarget"; +/** A preview overlay button that's off: BoardUI's light surface with a hairline, readable over any frame. */ +const overlayButtonOff = + "bg-background-primary-default/90 text-foreground-icon-secondary shadow-xs ring-1 ring-border-button-default hover:bg-background-primary-hover hover:text-foreground-icon-primary"; + // fallow-ignore-next-line complexity export const SnapToolbar = memo(function SnapToolbar() { const [gridPopoverOpen, setGridPopoverOpen] = useState(false); @@ -87,9 +91,7 @@ export const SnapToolbar = memo(function SnapToolbar() {