From 8d84f59d5511d8f2aa3225f3d3e7e1f05bb31c7e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 4 Sep 2026 01:29:39 -0400 Subject: [PATCH 1/5] feat(studio): header and inspector tabs on the shared primitives The header's view-mode toggle and the inspector's tab strip become real Tabs, so both gain the roving tabindex, arrow keys and Home/End they never had; undo, redo and Inspector become IconButton and Button; Export becomes the primary Button at the medium size. Capture stays an , wearing Button's recipe, because a button cannot save a file. Colour literals in StudioHeader.tsx drop to zero: the brand gradient moves into theme.css as two tokens and the Export's hand-written text colour becomes text-bg-0. Ratchet total 475 to 470. --- .../studio/src/components/PanelTabButton.tsx | 31 ---- .../src/components/RightPanelTabs.test.tsx | 145 ++++++++++++++++ .../studio/src/components/RightPanelTabs.tsx | 62 +++++++ .../src/components/StudioHeader.dom.test.tsx | 158 ++++++++++++++++++ .../src/components/StudioHeader.test.ts | 19 ++- .../studio/src/components/StudioHeader.tsx | 150 ++++++++--------- .../src/components/StudioRightPanel.tsx | 82 +++++---- packages/studio/src/components/ui/Button.tsx | 12 +- packages/studio/src/components/ui/index.ts | 2 +- packages/studio/src/styles/theme.css | 6 + 10 files changed, 519 insertions(+), 148 deletions(-) delete mode 100644 packages/studio/src/components/PanelTabButton.tsx create mode 100644 packages/studio/src/components/RightPanelTabs.test.tsx create mode 100644 packages/studio/src/components/RightPanelTabs.tsx create mode 100644 packages/studio/src/components/StudioHeader.dom.test.tsx diff --git a/packages/studio/src/components/PanelTabButton.tsx b/packages/studio/src/components/PanelTabButton.tsx deleted file mode 100644 index 1f87cf8ad8..0000000000 --- a/packages/studio/src/components/PanelTabButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Tooltip } from "./ui"; - -/** Tab-bar button for the right inspector panel header. */ -export function PanelTabButton({ - label, - tooltip, - active, - onClick, -}: { - label: string; - tooltip: string; - active: boolean; - onClick: () => void; -}) { - return ( - - - - ); -} diff --git a/packages/studio/src/components/RightPanelTabs.test.tsx b/packages/studio/src/components/RightPanelTabs.test.tsx new file mode 100644 index 0000000000..a4230fee3a --- /dev/null +++ b/packages/studio/src/components/RightPanelTabs.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment happy-dom +/** + * The inspector strip's keyboard behaviour, and the two things a reskin can + * silently break: which tab reads as selected, and how the global hotkey + * filters classify the elements (KTD13). + * + * The strip used to be `aria-pressed` buttons. Arrow keys did nothing, so the + * first two tests here are new behaviour, not a port. + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { RightPanelTabs, type RightPanelTabDescriptor } from "./RightPanelTabs"; +import { isTypingTarget } from "../utils/typingTarget"; +import { shouldIgnorePlaybackShortcutTarget } from "../player/lib/playbackShortcuts"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const IDS = ["design", "layers", "renders", "variables"]; + +let mounted: { root: Root; host: HTMLElement } | null = null; + +afterEach(() => { + if (!mounted) return; + const { root, host } = mounted; + mounted = null; + act(() => root.unmount()); + host.remove(); +}); + +/** + * The strip is controlled by the panel's own state, so the harness owns that + * state too: a mount whose `active` never moved would make every second + * selection look like a no-op the real panel does not have. + */ +function mount(initialActive: string[]): { + host: HTMLElement; + selections: ReturnType; +} { + const selections = vi.fn(); + + function Harness() { + const [active, setActive] = React.useState(initialActive); + const tabs: RightPanelTabDescriptor[] = IDS.map((id) => ({ + id, + label: id, + tooltip: `${id} tooltip`, + active: active.includes(id), + onSelect: () => { + selections(id); + setActive([id]); + }, + })); + return ; + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + mounted = { root, host }; + act(() => root.render()); + return { host, selections }; +} + +function tab(host: HTMLElement, id: string): HTMLElement { + const el = host.querySelector(`[data-tab-id="${id}"]`); + if (!el) throw new Error(`no tab for ${id}`); + return el; +} + +function selected(host: HTMLElement): string | null { + return ( + host.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute("data-tab-id") ?? null + ); +} + +/** Base UI moves the roving tabindex synchronously and the focus one task later. */ +async function arrow(key: string): Promise { + const target = document.activeElement ?? document.body; + act(() => { + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, composed: true })); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +it("moves from Design to Layers with ArrowRight and asks the panel to switch", async () => { + const { host, selections } = mount(["design"]); + act(() => tab(host, "design").focus()); + + await arrow("ArrowRight"); + + expect(document.activeElement).toBe(tab(host, "layers")); + expect(selections).toHaveBeenCalledWith("layers"); +}); + +it("jumps to the first and last tab with Home and End", async () => { + const { host, selections } = mount(["design"]); + act(() => tab(host, "design").focus()); + + await arrow("End"); + expect(document.activeElement).toBe(tab(host, "variables")); + expect(selections).toHaveBeenLastCalledWith("variables"); + + await arrow("Home"); + expect(document.activeElement).toBe(tab(host, "design")); + expect(selections).toHaveBeenLastCalledWith("design"); +}); + +it("selects the tab whose content is on screen", () => { + const { host } = mount(["renders"]); + + expect(selected(host)).toBe("renders"); +}); + +it("leaves every tab unselected when the panel shows something the strip has no tab for", () => { + // Block params take over the panel body without a tab of their own. The old + // buttons all read unpressed in that state; nothing should read selected now. + const { host } = mount([]); + + expect(selected(host)).toBe(null); +}); + +it("keeps a second open pane looking open in the legacy split inspector", () => { + // Design and Layers render together there. Only one tab can carry + // aria-selected, so the other has to keep the selected look or the strip + // would claim a pane is closed while it is on screen. + const { host } = mount(["design", "layers"]); + + expect(selected(host)).toBe("design"); + expect(tab(host, "layers").className).toContain("bg-hover"); +}); + +it("classifies its tabs for the hotkey filters exactly as the old buttons did (KTD13)", () => { + // The old strip rendered plain + /> - - + /> + {/* A real download link, so it wears Button's recipe rather than being + one: `download` on an is what saves the frame, and no + - + diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 248ff383cb..a0e9606a29 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -9,7 +9,7 @@ import { BlockParamsPanel } from "./editor/BlockParamsPanel"; import { RenderQueuePanel } from "./renders/RenderQueuePanel"; import { SlideshowPanel } from "./panels/SlideshowPanel"; import { VariablesPanel } from "./panels/VariablesPanel"; -import { PanelTabButton } from "./PanelTabButton"; +import { RightPanelTabs, type RightPanelTabDescriptor } from "./RightPanelTabs"; import type { RenderJob } from "./renders/useRenderQueue"; import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability"; import { useSlideshowPersist } from "../hooks/useSlideshowPersist"; @@ -339,6 +339,51 @@ export function StudioRightPanel({ const renderQueuePanel = ; + // Slideshow appears only for a slideshow composition, so the strip is built + // rather than written out: a tab that is not in this list is not reachable by + // an arrow key either. + const inspectorTabs: RightPanelTabDescriptor[] = [ + { + id: "design", + label: "Design", + tooltip: "Element styles and properties", + active: designPaneOpen, + onSelect: () => handleInspectorPaneButtonClick("design"), + }, + { + id: "layers", + label: "Layers", + tooltip: "Composition layer stack", + active: layersPaneOpen, + onSelect: () => handleInspectorPaneButtonClick("layers"), + }, + { + id: "renders", + label: renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders", + tooltip: "Render queue and exports", + active: rightPanelTab === "renders", + onSelect: () => setRightPanelTab("renders"), + }, + ...(isSlideshowComposition + ? [ + { + id: "slideshow", + label: "Slideshow", + tooltip: "Slideshow branching editor", + active: rightPanelTab === "slideshow", + onSelect: () => setRightPanelTab("slideshow"), + }, + ] + : []), + { + id: "variables", + label: "Variables", + tooltip: "Template variables — declare, preview with values", + active: rightPanelTab === "variables", + onSelect: () => setRightPanelTab("variables"), + }, + ]; + return ( <> {/* Vertical resize divider: 3px visible seam, 13px hit zone via the inner div. */} @@ -376,40 +421,7 @@ export function StudioRightPanel({ ) : ( <> -
- handleInspectorPaneButtonClick("design")} - /> - handleInspectorPaneButtonClick("layers")} - /> - 0 ? `Renders (${renderJobs.length})` : "Renders"} - tooltip="Render queue and exports" - active={rightPanelTab === "renders"} - onClick={() => setRightPanelTab("renders")} - /> - {isSlideshowComposition && ( - setRightPanelTab("slideshow")} - /> - )} - setRightPanelTab("variables")} - /> -
+
{rightPanelTab === "block-params" && activeBlockParams ? ( = { ), }; -/** The three control heights, 24 / 28 / 32 px, from `--spacing-ctl-*`. */ -const sizeStyles: Record = { +/** + * The three control heights, 24 / 28 / 32 px, from `--spacing-ctl-*`. + * + * Exported beside `buttonBase` and `buttonVariants` for the one header control + * a `