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..2e252450fe --- /dev/null +++ b/packages/studio/src/components/RightPanelTabs.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +/** + * The strip's keyboard behaviour, which tab reads as selected, and how the hotkey + * filters classify the elements (KTD13). The arrow-key tests 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 harness owns the active state too, so a selection moves as it does in the real panel. */ +function mount( + initialActive: string[], + activateOnFocus = true, +): { + 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("only moves focus on an arrow key when selection would toggle a pane", async () => { + const { host, selections } = mount(["design"], false); + act(() => tab(host, "design").focus()); + + await arrow("ArrowRight"); + + expect(document.activeElement).toBe(tab(host, "layers")); + expect(selections).not.toHaveBeenCalled(); +}); + +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..75c1cb5dad 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 so the Capture download `` can wear the same recipe as a Button. + */ +export const buttonSizes: Record = { sm: "h-ctl-sm px-2 gap-1 rounded-sm text-step-11", md: "h-ctl px-3 gap-1.5 rounded-md text-step-12", lg: "h-ctl-lg px-4 gap-2 rounded-md text-step-13", @@ -107,7 +110,7 @@ export const Button = forwardRef( // `aria-disabled` as well as `disabled`: assistive tech announces the // state even where the native attribute is filtered out of the tree. aria-disabled={isDisabled || undefined} - className={cn(buttonBase, buttonVariants[variant], sizeStyles[size], className)} + className={cn(buttonBase, buttonVariants[variant], buttonSizes[size], className)} {...props} > {leading} diff --git a/packages/studio/src/components/ui/index.ts b/packages/studio/src/components/ui/index.ts index 1055707d7e..ad56b2dc81 100644 --- a/packages/studio/src/components/ui/index.ts +++ b/packages/studio/src/components/ui/index.ts @@ -1,7 +1,7 @@ // Studio's shared UI primitives. Every control in the app is meant to come // from here, so a look or a keyboard behaviour is decided once. export { cn } from "./cn"; -export { Button, buttonBase, buttonVariants } from "./Button"; +export { Button, buttonBase, buttonSizes, buttonVariants } from "./Button"; export type { ButtonSize, ButtonVariant, PreviewState } from "./Button"; export { IconButton } from "./IconButton"; export { Tab, TabPanel, Tabs, TabsList } from "./Tabs"; diff --git a/packages/studio/src/styles/hex-baseline.json b/packages/studio/src/styles/hex-baseline.json index eb038c5e0d..752f99acb6 100644 --- a/packages/studio/src/styles/hex-baseline.json +++ b/packages/studio/src/styles/hex-baseline.json @@ -1,12 +1,11 @@ { - "total": 423, + "total": 418, "files": { "packages/studio/src/captions/components/CaptionOverlay.tsx": 5, "packages/studio/src/captions/components/CaptionTimeline.tsx": 11, "packages/studio/src/captions/generator.ts": 1, "packages/studio/src/captions/parser.ts": 1, "packages/studio/src/captions/types.ts": 4, - "packages/studio/src/components/StudioHeader.tsx": 5, "packages/studio/src/components/StudioToast.tsx": 10, "packages/studio/src/components/TimelineToolbar.tsx": 2, "packages/studio/src/components/editor/ArcPathControls.tsx": 1, diff --git a/packages/studio/src/styles/theme.css b/packages/studio/src/styles/theme.css index 95520e9631..d40daf17e5 100644 --- a/packages/studio/src/styles/theme.css +++ b/packages/studio/src/styles/theme.css @@ -41,6 +41,11 @@ --color-media: #00e3ff; --color-container: #f5a623; + /* The two stops of the HeyGen brand gradient, worn by the header logo. Fixed brand values + * rather than roles, so they are not reachable as a background or text colour by mistake. */ + --color-brand-gradient-from: #06e3fa; + --color-brand-gradient-to: #4fdb5e; + /* -------------------------------------------------------------- geometry */ --radius-sm: 0.25rem; diff --git a/scripts/check-no-main-deletions.mjs b/scripts/check-no-main-deletions.mjs index 7f5975e731..691d0a554b 100644 --- a/scripts/check-no-main-deletions.mjs +++ b/scripts/check-no-main-deletions.mjs @@ -44,6 +44,10 @@ export const ALLOWED_DELETIONS = new Map([ "packages/studio/src/components/StudioGlobalDragOverlay.tsx", "the full-screen drop-to-add box is replaced by a landing preview on the timeline; no remaining references", ], + [ + "packages/studio/src/components/PanelTabButton.tsx", + "replaced by the shared Tabs primitive in RightPanelTabs; no remaining references", + ], [ "packages/studio/postcss.config.js", "Tailwind v4 migration: postcss replaced by @tailwindcss/vite",