feat(studio): header and inspector tabs on the shared primitives (#3624)

* 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 <a download>,
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.

* docs(studio): shorten the header and inspector tab comments to fit the comment gate

* chore(studio): bank the lower colour-literal baseline for StudioHeader

* fix(studio): arrow keys only move focus when the inspector strip toggles panes

* chore(studio): allow deleting PanelTabButton, replaced by the shared Tabs primitive
This commit is contained in:
Miguel Ángel
2026-09-19 15:49:17 -04:00
committed by GitHub
parent fd6049f356
commit 9bec64a293
12 changed files with 514 additions and 150 deletions
@@ -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 (
<Tooltip label={tooltip} side="bottom">
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
active
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
}`}
>
{label}
</button>
</Tooltip>
);
}
@@ -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<typeof vi.fn>;
} {
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 <RightPanelTabs tabs={tabs} activateOnFocus={activateOnFocus} />;
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
mounted = { root, host };
act(() => root.render(<Harness />));
return { host, selections };
}
function tab(host: HTMLElement, id: string): HTMLElement {
const el = host.querySelector<HTMLElement>(`[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<void> {
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 <button>s: not a typing target, and claimed by
// the playback filter through its `button` selector. Base UI's tab is a
// <button> too, so both verdicts have to be unchanged.
const { host } = mount(["design"]);
const el = tab(host, "design");
expect(el.tagName).toBe("BUTTON");
expect(isTypingTarget(el)).toBe(false);
expect(shouldIgnorePlaybackShortcutTarget(el)).toBe(true);
});
@@ -0,0 +1,59 @@
/**
* The inspector tab strip on the shared Tabs primitive. The selected tab is derived from the
* panel's state; in the legacy split inspector two can be open, only one holds `aria-selected`.
*/
import { Tab, Tabs, TabsList, Tooltip, cn } from "./ui";
export interface RightPanelTabDescriptor {
/** Stable id, also the `data-tab-id` attribute the strip is queried by. */
id: string;
label: string;
tooltip: string;
/** Whether this tab's content is on screen right now. */
active: boolean;
/** Run when the strip moves to this tab, by click or by arrow key. */
onSelect: () => void;
}
/** The look `aria-selected` gives the selected tab, for a second open pane. */
const SELECTED_LOOK = "bg-hover text-text-0";
export function RightPanelTabs({
tabs,
activateOnFocus = true,
}: {
tabs: readonly RightPanelTabDescriptor[];
/** False when `onSelect` toggles a pane, so an arrow key only moves focus. */
activateOnFocus?: boolean;
}) {
// `null` when the layout holds a tab this strip does not show (block params),
// which leaves every tab unselected, exactly as the old buttons did.
const value = tabs.find((tab) => tab.active)?.id ?? null;
return (
<Tabs
value={value}
onValueChange={(next) => {
tabs.find((tab) => tab.id === next)?.onSelect();
}}
>
<TabsList
aria-label="Inspector panels"
activateOnFocus={activateOnFocus}
className="flex min-w-0 items-center gap-1 overflow-hidden rounded-none border-b border-border-strong bg-transparent px-3 py-2"
>
{tabs.map((tab) => (
<Tooltip key={tab.id} label={tab.tooltip} side="bottom">
<Tab
value={tab.id}
className={cn("h-ctl-lg rounded-lg px-3 font-medium", tab.active && SELECTED_LOOK)}
>
{tab.label}
</Tab>
</Tooltip>
))}
</TabsList>
</Tabs>
);
}
@@ -0,0 +1,153 @@
// @vitest-environment happy-dom
/**
* The header on the shared primitives: same controls, disabled history still explained,
* hotkey filters unchanged (KTD13). Contexts are mocked, not provided.
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { buttonSizes, buttonVariants } from "./ui";
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 editHistory = {
canUndo: false,
canRedo: false,
undoLabel: undefined as string | undefined,
redoLabel: undefined as string | undefined,
};
const renderQueue = { isRendering: false, ffmpegMissing: false };
vi.mock("../contexts/StudioContext", () => ({
useStudioShellContext: () => ({
projectId: "demo",
editHistory,
handleUndo: vi.fn(),
handleRedo: vi.fn(),
renderQueue,
}),
}));
vi.mock("../contexts/PanelLayoutContext", () => ({
usePanelLayoutContext: () => ({
effectiveRightCollapsed: false,
setRightCollapsed: vi.fn(),
setRightPanelTab: vi.fn(),
}),
}));
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
const { StudioHeader } = await import("./StudioHeader");
let mounted: { root: Root; host: HTMLElement } | null = null;
beforeEach(() => {
editHistory.canUndo = false;
editHistory.canRedo = false;
editHistory.undoLabel = undefined;
editHistory.redoLabel = undefined;
renderQueue.isRendering = false;
});
afterEach(() => {
if (!mounted) return;
const { root, host } = mounted;
mounted = null;
act(() => root.unmount());
host.remove();
});
function mount(): HTMLElement {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
mounted = { root, host };
act(() =>
root.render(
<StudioHeader
captureFrameHref="blob:frame"
captureFrameFilename="frame.png"
handleCaptureFrameClick={vi.fn()}
refreshCaptureFrameTime={vi.fn()}
inspectorButtonActive={false}
inspectorPanelActive={false}
/>,
),
);
return host;
}
function query(host: HTMLElement, selector: string): HTMLElement {
const el = host.querySelector<HTMLElement>(selector);
if (!el) throw new Error(`not rendered: ${selector}`);
return el;
}
/** Every class the recipe asks for, on the element the header rendered. */
function expectRecipe(el: HTMLElement, ...recipes: string[]): void {
const applied = new Set(el.className.split(/\s+/));
for (const recipe of recipes) {
for (const token of recipe.split(/\s+/)) {
expect(applied, `${token} missing from: ${el.className}`).toContain(token);
}
}
}
it("renders Export as the shared primary Button at the medium size", () => {
const host = mount();
expectRecipe(
query(host, '[data-testid="header-export"]'),
buttonVariants.primary,
buttonSizes.md,
);
});
it("disables Undo and Redo while the history is empty", () => {
const host = mount();
expect(query(host, '[aria-label="Undo"]').hasAttribute("disabled")).toBe(true);
expect(query(host, '[aria-label="Redo"]').hasAttribute("disabled")).toBe(true);
});
it("enables Undo once there is something to undo", () => {
editHistory.canUndo = true;
editHistory.undoLabel = "Move layer";
const host = mount();
expect(query(host, '[aria-label="Undo"]').hasAttribute("disabled")).toBe(false);
});
it("keeps Capture a real download link rather than a button", () => {
// `download` is what saves the frame. A Button here would render a <button>
// and the control would quietly stop downloading anything.
const host = mount();
const capture = query(host, '[aria-label="Capture current frame"]');
expect(capture.tagName).toBe("A");
expect(capture.getAttribute("download")).toBe("frame.png");
expectRecipe(capture, buttonSizes.md);
});
it("classifies the new header controls for the hotkey filters as the old ones were (KTD13)", () => {
// Every one of these was a <button> or an <a href> before the sweep: never a
// typing target, always claimed by the playback filter. A primitive that
// rendered a different element would leak or swallow hotkeys in silence.
const host = mount();
const controls = [
query(host, '[data-testid="header-export"]'),
query(host, '[aria-label="Undo"]'),
query(host, '[aria-label="Redo"]'),
query(host, '[aria-label="Inspector"]'),
query(host, '[aria-label="Capture current frame"]'),
];
for (const el of controls) {
expect(isTypingTarget(el), el.getAttribute("aria-label") ?? el.tagName).toBe(false);
expect(shouldIgnorePlaybackShortcutTarget(el), el.tagName).toBe(true);
}
});
@@ -1,5 +1,22 @@
import { describe, expect, it } from "vitest";
import { shouldOpenInspector } from "./StudioHeader";
import { historyTooltipLabel, shouldOpenInspector } from "./StudioHeader";
describe("historyTooltipLabel", () => {
// A disabled control gets no pointer events, so its tooltip cannot be opened
// to read. The wording is checked here instead of through the DOM.
it("names the action that would be undone", () => {
expect(historyTooltipLabel("undo", "Move layer")).toMatch(/^Undo Move layer \(.+\)$/);
});
it("names the action that would be redone", () => {
expect(historyTooltipLabel("redo", "Move layer")).toMatch(/^Redo Move layer \(.+\)$/);
});
it("keeps the shortcut when the history is empty", () => {
expect(historyTooltipLabel("undo", undefined)).toMatch(/^Undo \(.+\)$/);
expect(historyTooltipLabel("redo", null)).toMatch(/^Redo \(.+\)$/);
});
});
describe("shouldOpenInspector", () => {
it("opens when the panel is hidden", () => {
+69 -77
View File
@@ -4,7 +4,7 @@ import { getHistoryShortcutLabel } from "../utils/studioHelpers";
import { useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { Tooltip } from "./ui";
import { Button, buttonBase, buttonSizes, buttonVariants, cn, IconButton, Tooltip } from "./ui";
export interface StudioHeaderProps {
captureFrameHref: string;
@@ -40,8 +40,8 @@ function HyperframesLogo() {
y2="37.482"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#06E3FA" />
<stop offset="1" stopColor="#4FDB5E" />
<stop stopColor="var(--color-brand-gradient-from)" />
<stop offset="1" stopColor="var(--color-brand-gradient-to)" />
</linearGradient>
<linearGradient
id="hf-g1"
@@ -51,8 +51,8 @@ function HyperframesLogo() {
y2="6.303"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#06E3FA" />
<stop offset="1" stopColor="#4FDB5E" />
<stop stopColor="var(--color-brand-gradient-from)" />
<stop offset="1" stopColor="var(--color-brand-gradient-to)" />
</linearGradient>
</defs>
{/* heygen label */}
@@ -138,6 +138,16 @@ function HyperframesLogo() {
);
}
/** The Undo / Redo tooltip: the shortcut always, the last action's name when there is one. */
export function historyTooltipLabel(
action: "undo" | "redo",
lastAction: string | null | undefined,
): string {
const shortcut = getHistoryShortcutLabel(action);
const verb = action === "undo" ? "Undo" : "Redo";
return lastAction ? `${verb} ${lastAction} (${shortcut})` : `${verb} (${shortcut})`;
}
/**
* Does the header's Inspector button open the panel, or close it?
*
@@ -174,68 +184,44 @@ export function StudioHeader({
const ffmpegMissing = renderQueue.ffmpegMissing;
return (
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 shrink-0">
<div className="flex items-center justify-between h-10 px-3 bg-surface border-b border-border-strong shrink-0">
{/* Left: logo + project name */}
<div className="flex items-center gap-3">
<HyperframesLogo />
<span className="text-neutral-700 select-none" aria-hidden="true">
<span className="text-text-5 select-none" aria-hidden="true">
|
</span>
<span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
<span className="text-step-11 font-medium text-text-1">{projectId}</span>
</div>
{/* Right: toolbar buttons */}
<div className="flex items-center gap-1.5">
<Tooltip
label={
editHistory.undoLabel
? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
: `Undo (${getHistoryShortcutLabel("undo")})`
}
side="bottom"
>
<button
type="button"
<Tooltip label={historyTooltipLabel("undo", editHistory.undoLabel)} side="bottom">
<IconButton
aria-label="Undo"
icon={<RotateCcw size={14} />}
disabled={!editHistory.canUndo}
onClick={() => {
trackStudioEvent("toolbar_action", { action: "undo" });
void handleUndo();
}}
disabled={!editHistory.canUndo}
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors active:scale-[0.98] ${
editHistory.canUndo
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
: "text-neutral-700 cursor-default"
}`}
aria-label="Undo"
>
<RotateCcw size={14} />
</button>
/>
</Tooltip>
<Tooltip
label={
editHistory.redoLabel
? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
: `Redo (${getHistoryShortcutLabel("redo")})`
}
side="bottom"
>
<button
type="button"
<Tooltip label={historyTooltipLabel("redo", editHistory.redoLabel)} side="bottom">
<IconButton
aria-label="Redo"
icon={<RotateCw size={14} />}
disabled={!editHistory.canRedo}
onClick={() => {
trackStudioEvent("toolbar_action", { action: "redo" });
void handleRedo();
}}
disabled={!editHistory.canRedo}
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors active:scale-[0.98] ${
editHistory.canRedo
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
: "text-neutral-700 cursor-default"
}`}
aria-label="Redo"
>
<RotateCw size={14} />
</button>
/>
</Tooltip>
<Tooltip label={capturing ? "Capturing frame…" : "Capture current frame"} side="bottom">
{/* A real download link, so it wears Button's recipe rather than being
one: `download` on an <a> is what saves the frame, and no <button>
can do that. `enabled:` never matches a link, so the ghost
variant's hover look is repeated unprefixed here. */}
<a
href={captureFrameHref}
download={captureFrameFilename}
@@ -250,11 +236,14 @@ export function StudioHeader({
onFocus={refreshCaptureFrameTime}
onPointerDown={refreshCaptureFrameTime}
aria-disabled={capturing || undefined}
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium transition-colors ${
className={cn(
buttonBase,
buttonVariants.ghost,
buttonSizes.md,
capturing
? "text-neutral-600 cursor-default"
: "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 active:scale-[0.98]"
}`}
? "text-text-4 cursor-default"
: "hover:bg-hover hover:text-text-0 active:scale-[0.98]",
)}
aria-label={capturing ? "Capturing frame" : "Capture current frame"}
>
{capturing ? (
@@ -285,8 +274,29 @@ export function StudioHeader({
</a>
</Tooltip>
<Tooltip label="Inspector" side="bottom">
<button
type="button"
<Button
variant="ghost"
aria-label="Inspector"
aria-pressed={inspectorButtonActive}
className={cn(
"border",
inspectorButtonActive
? "border-accent/30 bg-accent/10 text-accent"
: "border-transparent",
)}
icon={
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
</svg>
}
onClick={() => {
if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
@@ -299,27 +309,9 @@ export function StudioHeader({
// the panel shouldn't deselect the element.
setRightCollapsed(true);
}}
aria-pressed={inspectorButtonActive}
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors active:scale-[0.98] ${
inspectorButtonActive
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
}`}
aria-label="Inspector"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
</svg>
Inspector
</button>
</Button>
</Tooltip>
<Tooltip
label={
@@ -331,8 +323,9 @@ export function StudioHeader({
}
side="bottom"
>
<button
type="button"
<Button
variant="primary"
data-testid="header-export"
disabled={isRendering}
onClick={() => {
if (isRendering) return;
@@ -346,10 +339,9 @@ export function StudioHeader({
if (ffmpegMissing) return;
onExport?.();
}}
className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] enabled:hover:brightness-110 transition-[filter,transform] enabled:active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
>
{isRendering ? "Rendering…" : "Export"}
</button>
</Button>
</Tooltip>
</div>
</div>
@@ -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 = <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({
<CaptionPropertyPanel iframeRef={previewIframeRef} />
) : (
<>
<div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
<PanelTabButton
label="Design"
tooltip="Element styles and properties"
active={designPaneOpen}
onClick={() => handleInspectorPaneButtonClick("design")}
/>
<PanelTabButton
label="Layers"
tooltip="Composition layer stack"
active={layersPaneOpen}
onClick={() => handleInspectorPaneButtonClick("layers")}
/>
<PanelTabButton
label={renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
tooltip="Render queue and exports"
active={rightPanelTab === "renders"}
onClick={() => setRightPanelTab("renders")}
/>
{isSlideshowComposition && (
<PanelTabButton
label="Slideshow"
tooltip="Slideshow branching editor"
active={rightPanelTab === "slideshow"}
onClick={() => setRightPanelTab("slideshow")}
/>
)}
<PanelTabButton
label="Variables"
tooltip="Template variables — declare, preview with values"
active={rightPanelTab === "variables"}
onClick={() => setRightPanelTab("variables")}
/>
</div>
<RightPanelTabs tabs={inspectorTabs} activateOnFocus={STUDIO_FLAT_INSPECTOR_ENABLED} />
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
{rightPanelTab === "block-params" && activeBlockParams ? (
<BlockParamsPanel
+6 -3
View File
@@ -71,8 +71,11 @@ export const buttonVariants: Record<ButtonVariant, string> = {
),
};
/** The three control heights, 24 / 28 / 32 px, from `--spacing-ctl-*`. */
const sizeStyles: Record<ButtonSize, string> = {
/**
* The three control heights, 24 / 28 / 32 px, from `--spacing-ctl-*`.
* Exported so the Capture download `<a href>` can wear the same recipe as a Button.
*/
export const buttonSizes: Record<ButtonSize, string> = {
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<HTMLButtonElement, ButtonProps>(
// `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}
+1 -1
View File
@@ -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";
+1 -2
View File
@@ -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,
+5
View File
@@ -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;
+4
View File
@@ -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",