mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user