Refine HeroUI editor spacing, cards and responsive alignment

This commit is contained in:
webadderall
2026-09-19 16:56:29 +10:00
parent 93be1935ac
commit 3931f23e05
25 changed files with 928 additions and 658 deletions
+11 -6
View File
@@ -19,20 +19,24 @@ helper installation. The dependency lockfile belongs to this branch.
To go back, close the development app and run `npm run dev` from the original
`recordly` directory. No reset, stash, or file restoration is needed. You can
keep both checkouts while comparing them. If this commit is merged later,
`git revert <migration-commit>` reverses the migration without rewriting history.
keep both checkouts while comparing them. To undo just the UI refinement pass,
use `git revert <refinement-commit>`. To undo the entire migration after merging,
revert the branch commits newest-first, including the original `93be193` migration.
## UI approach
The controls use HeroUI React 3.2.6 and its default light/dark theme, following
[the official component demos](https://heroui.com/en/docs/react/components).
This includes buttons, fields, switches, sliders, tabs, toggle groups, radios,
This includes buttons, fields, switches, sliders, tabs, tag groups, toggle groups, radios,
selects, modals, popovers, menus, tooltips, color pickers, progress indicators,
skeletons, and toasts. React 19 and Tailwind 4 satisfy HeroUI v3 requirements.
The former Radix, Sonner, and third-party color picker dependencies are removed.
The editor uses docked surfaces, a fixed-width inspector, aligned toolbars,
consistent spacing, and restrained selection colors. Floating layers keep one
The editor uses a floating inspector card, an open canvas and timeline, aligned
toolbars, consistent spacing, and restrained selection colors. Advanced controls
live behind a per-section switch; changing views preserves project values.
Background types and other exclusive choices use TagGroup. The header follows
native fullscreen state and keeps project titles centered at narrow widths. Floating layers keep one
surface instead of nesting cards and shadows. Timeline colors follow the theme
and retain the distinction between clip types. The recorder keeps its compact
desktop layout.
@@ -60,7 +64,8 @@ Browser tests use an explicit mocked Electron bridge and a generated six-second
video fixture; they never start a real screen recording. They cover control
callbacks and keyboard behavior, modal focus, export settings, presets, cropping,
annotation formatting/undo, project menus, recorder popovers, countdown and update
windows, theme switching, and a smaller desktop layout. Screenshots and failure
windows, theme switching, Advanced state, color editing, and header/playback
alignment from 800–1440px with and without macOS window controls. Screenshots and failure
traces go to the ignored `test-results/` directory.
`npm run dev:ui` starts only Vite for browser inspection; the component fixture
+4
View File
@@ -864,6 +864,10 @@ interface Window {
onMenuLoadProject: (callback: () => void) => () => void;
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
getWindowChrome: () => Promise<{ trafficLightsVisible: boolean }>;
onWindowChromeChanged: (
callback: (chrome: { trafficLightsVisible: boolean }) => void,
) => () => void;
getPlatform: () => Promise<string>;
getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>;
revealInFolder: (
+12 -1
View File
@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { app, ipcMain } from "electron";
import { app, BrowserWindow, ipcMain } from "electron";
import { hasAppSetting, readAppSettingsStore, writeAppSettingsStore } from "../../appSettingsStore";
import { hideCursor } from "../../cursorHider";
import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows";
@@ -47,6 +47,17 @@ export function registerSettingsHandlers() {
return app.getVersion();
});
ipcMain.handle("get-window-chrome", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
return {
trafficLightsVisible:
process.platform === "darwin" &&
!!win &&
!win.isFullScreen() &&
!win.isSimpleFullScreen(),
};
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
+9
View File
@@ -940,6 +940,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("menu-save-project-as", listener);
return () => ipcRenderer.removeListener("menu-save-project-as", listener);
},
getWindowChrome: () => ipcRenderer.invoke("get-window-chrome"),
onWindowChromeChanged: (callback: (chrome: { trafficLightsVisible: boolean }) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
chrome: { trafficLightsVisible: boolean },
) => callback(chrome);
ipcRenderer.on("window-chrome-changed", listener);
return () => ipcRenderer.removeListener("window-chrome-changed", listener);
},
getPlatform: () => {
return ipcRenderer.invoke("get-platform");
},
+12 -1
View File
@@ -919,7 +919,7 @@ export function createEditorWindow(): BrowserWindow {
}),
...(isMac && {
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
trafficLightPosition: { x: 16, y: 20 },
}),
autoHideMenuBar: !isMac,
transparent: false,
@@ -938,6 +938,17 @@ export function createEditorWindow(): BrowserWindow {
},
});
const publishWindowChrome = () => {
if (!win.isDestroyed())
win.webContents.send("window-chrome-changed", {
trafficLightsVisible: isMac && !win.isFullScreen() && !win.isSimpleFullScreen(),
});
};
win.on("enter-full-screen", publishWindowChrome);
win.on("leave-full-screen", publishWindowChrome);
win.on("resize", publishWindowChrome);
win.webContents.on("did-finish-load", publishWindowChrome);
win.once("ready-to-show", () => {
console.log(`[PERF:MAIN] Editor Window: ready-to-show in ${Date.now() - perfStart}ms`);
win.show();
+66
View File
@@ -0,0 +1,66 @@
import { Tag, TagGroup } from "@heroui/react";
import type { ComponentProps, ReactNode } from "react";
/** Mutually exclusive settings, with HeroUI's keyboard-accessible tag selection. */
export function ChoiceGroup({
value,
onValueChange,
children,
className,
...props
}: {
value?: string;
onValueChange?: (value: string) => void;
children: ReactNode;
className?: string;
type?: "single";
size?: "sm" | "md" | "lg";
fullWidth?: boolean;
"aria-label"?: string;
}) {
return (
<TagGroup
size="lg"
aria-label={props["aria-label"]}
selectionMode="single"
disallowEmptySelection
selectedKeys={value ? [value] : []}
onSelectionChange={(keys) => {
if (keys !== "all") {
const key = Array.from(keys)[0];
if (key !== undefined) onValueChange?.(String(key));
}
}}
>
<TagGroup.List className={className ?? "flex flex-wrap gap-2"}>
{children}
</TagGroup.List>
</TagGroup>
);
}
export function ChoiceItem({
value,
title,
children,
className,
...props
}: Omit<ComponentProps<typeof Tag>, "id"> & {
value: string;
title?: string;
"aria-label"?: string;
}) {
return (
<Tag
{...props}
id={value}
textValue={
props.textValue ??
title ??
(typeof children === "string" ? children : props["aria-label"])
}
className={`min-h-9 justify-center ${className ?? ""}`}
>
{children}
</Tag>
);
}
+35 -12
View File
@@ -32,13 +32,6 @@ export function ColorPalette({ color = "#000000", colors, onChange }: PalettePro
</ColorSwatchPicker.Item>
))}
</ColorSwatchPicker>
<ColorField
aria-label="Hex color"
value={color}
onChange={(value) => value && onChange({ hex: value.toString("hex") })}
>
<Input />
</ColorField>
</div>
);
}
@@ -46,19 +39,34 @@ export function ColorControl({
value,
onChange,
label,
colors,
compact = false,
onClear,
}: {
value: string;
onChange: (value: string) => void;
label: string;
colors?: readonly string[];
compact?: boolean;
onClear?: () => void;
}) {
return (
<ColorPicker value={value} onChange={(color) => onChange(color.toString("hex"))}>
<Button variant="secondary" aria-label={label}>
<ColorPicker
value={value === "transparent" ? "#00000000" : value}
onChange={(color) => onChange(color.toString("hex"))}
>
<Button
variant="secondary"
aria-label={label}
className="h-10 min-w-0 max-w-full gap-2 px-3"
>
<ColorSwatch size="sm" />
{label}
<span className="truncate">
{compact ? (value === "transparent" ? "None" : value.toUpperCase()) : label}
</span>
</Button>
<ColorPicker.Popover>
<Popover.Dialog aria-label={label} className="flex w-64 flex-col gap-3">
<Popover.Dialog aria-label={label} className="flex w-64 flex-col gap-4 p-4">
<ColorArea colorSpace="hsb" xChannel="saturation" yChannel="brightness">
<ColorArea.Thumb />
</ColorArea>
@@ -67,10 +75,25 @@ export function ColorControl({
<ColorSlider.Thumb />
</ColorSlider.Track>
</ColorSlider>
{colors && (
<ColorSwatchPicker aria-label="Preset colors">
{colors.map((color) => (
<ColorSwatchPicker.Item key={color} color={color}>
<ColorSwatchPicker.Swatch />
<ColorSwatchPicker.Indicator />
</ColorSwatchPicker.Item>
))}
</ColorSwatchPicker>
)}
<ColorField>
<Label>{label}</Label>
<Label>Hex color</Label>
<Input />
</ColorField>
{onClear && (
<Button variant="ghost" onClick={onClear}>
Clear background
</Button>
)}
</Popover.Dialog>
</ColorPicker.Popover>
</ColorPicker>
@@ -6,7 +6,6 @@ import {
AlignLeft,
AlignRight,
TextB as Bold,
CaretDown as ChevronDown,
ImageSquare as ImageIcon,
Info,
TextItalic as Italic,
@@ -20,7 +19,7 @@ import { ColorControl, ColorPalette } from "@/components/ui/color-picker";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "@/components/ui/toast";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -143,7 +142,7 @@ export function AnnotationSettingsPanel({
return (
<Card className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden rounded-none bg-transparent p-0 shadow-none">
<div className="flex-1 min-h-0 p-4 overflow-y-auto custom-scrollbar">
<div className="flex-1 min-h-0 px-5 pb-6 pt-1 overflow-y-auto custom-scrollbar">
<div className="mb-6">
{/* Type Selector */}
<Tabs
@@ -183,9 +182,9 @@ export function AnnotationSettingsPanel({
</TabsList>
{/* Text Content */}
<TabsContent value="text" className="mt-0 space-y-4">
<TabsContent value="text" className="mt-0 space-y-4 p-0">
<div>
<label className="text-xs font-medium text-foreground mb-2 block">
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.textContent")}
</label>
<TextArea
@@ -202,7 +201,7 @@ export function AnnotationSettingsPanel({
{/* Font Family & Size */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-foreground mb-2 block">
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.fontStyle")}
</label>
<Select
@@ -248,7 +247,7 @@ export function AnnotationSettingsPanel({
</Select>
</div>
<div>
<label className="text-xs font-medium text-foreground mb-2 block">
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.size")}
</label>
<Select
@@ -383,110 +382,40 @@ export function AnnotationSettingsPanel({
{/* Colors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-foreground mb-2 block">
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.textColor")}
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-9 justify-start gap-2 px-2"
>
<div
className="w-4 h-4 rounded-full border border-foreground/20"
style={{
backgroundColor: annotation.style.color,
}}
/>
<span className="text-xs text-muted-foreground truncate flex-1 text-left">
{annotation.style.color}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3">
<ColorPalette
color={annotation.style.color}
colors={colorPalette}
onChange={(color) => {
onStyleChange({ color: color.hex });
}}
style={{
borderRadius: "8px",
}}
/>
</PopoverContent>
</Popover>
<ColorControl
value={annotation.style.color}
label={t("annotations.textColor")}
onChange={(color) => onStyleChange({ color })}
colors={colorPalette}
compact
/>
</div>
<div>
<label className="text-xs font-medium text-foreground mb-2 block">
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.background")}
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-9 justify-start gap-2 px-2"
>
<div className="w-4 h-4 rounded-full border border-foreground/20 relative overflow-hidden">
<div className="absolute inset-0 checkerboard-bg opacity-50" />
<div
className="absolute inset-0"
style={{
backgroundColor:
annotation.style
.backgroundColor,
}}
/>
</div>
<span className="text-xs text-muted-foreground truncate flex-1 text-left">
{annotation.style.backgroundColor ===
"transparent"
? t("annotations.none")
: "Color"}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3">
<ColorPalette
color={
annotation.style.backgroundColor ===
"transparent"
? "#000000"
: annotation.style.backgroundColor
}
colors={colorPalette}
onChange={(color) => {
onStyleChange({
backgroundColor: color.hex,
});
}}
style={{
borderRadius: "8px",
}}
/>
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-xs h-7"
onClick={() => {
onStyleChange({
backgroundColor: "transparent",
});
}}
>
{t("annotations.clearBackground")}
</Button>
</PopoverContent>
</Popover>
<ColorControl
value={annotation.style.backgroundColor}
label={t("annotations.background")}
onChange={(color) =>
onStyleChange({ backgroundColor: color })
}
colors={colorPalette}
compact
onClear={() =>
onStyleChange({ backgroundColor: "transparent" })
}
/>
</div>
</div>
</div>
</TabsContent>
{/* Image Upload */}
<TabsContent value="image" className="mt-0 space-y-4">
<TabsContent value="image" className="mt-0 space-y-4 p-0">
<input
type="file"
ref={fileInputRef}
@@ -518,9 +447,9 @@ export function AnnotationSettingsPanel({
</p>
</TabsContent>
<TabsContent value="figure" className="mt-0 space-y-4">
<TabsContent value="figure" className="mt-0 space-y-4 p-0">
<div>
<label className="text-xs font-medium text-foreground mb-3 block">
<label className="text-sm font-medium text-foreground mb-3 block">
{t("annotations.arrowDirection")}
</label>
<div className="grid grid-cols-4 gap-2">
@@ -576,7 +505,7 @@ export function AnnotationSettingsPanel({
</div>
<div>
<label className="text-xs font-medium text-foreground mb-2 block">
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.strokeWidth", undefined, {
width: annotation.figureData?.strokeWidth || 4,
})}
@@ -596,58 +525,31 @@ export function AnnotationSettingsPanel({
min={1}
max={6}
step={1}
className="w-full"
className="my-3 w-full"
/>
</div>
<div>
<label className="text-xs font-medium text-foreground mb-2 block">
<label className="text-sm font-medium text-foreground mb-2 block">
{t("annotations.arrowColor")}
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-10 justify-start gap-2"
>
<div
className="w-5 h-5 rounded-full border border-foreground/20"
style={{
backgroundColor:
annotation.figureData?.color || "#2563EB",
}}
/>
<span className="text-xs text-muted-foreground truncate flex-1 text-left">
{annotation.figureData?.color || "#2563EB"}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3">
<ColorPalette
color={annotation.figureData?.color || "#2563EB"}
colors={colorPalette}
onChange={(color) => {
const newFigureData: FigureData = {
...annotation.figureData!,
color: color.hex,
};
onFigureDataChange?.(newFigureData);
}}
style={{
borderRadius: "8px",
}}
/>
</PopoverContent>
</Popover>
<ColorControl
value={annotation.figureData?.color || "#2563EB"}
label={t("annotations.arrowColor")}
onChange={(color) =>
onFigureDataChange?.({ ...annotation.figureData!, color })
}
colors={colorPalette}
compact
/>
</div>
</TabsContent>
<TabsContent value="blur" className="mt-0 space-y-4">
<div className="p-4 bg-foreground/5 rounded-xl border border-foreground/10 flex flex-col items-center">
<div className="w-full space-y-3">
<TabsContent value="blur" className="mt-0 space-y-4 p-0">
<div className="flex flex-col items-center">
<div className="w-full space-y-5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground">
<span className="text-sm font-medium text-foreground">
{t("annotations.blurStrength", undefined, {
strength: annotation.blurIntensity ?? 20,
})}
@@ -666,9 +568,9 @@ export function AnnotationSettingsPanel({
/>
</div>
<div className="w-full space-y-3 mt-4">
<div className="w-full space-y-5 mt-4">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground">
<span className="text-sm font-medium text-foreground">
{t(
"annotations.solidColor",
"Solid Color (Censorship)",
@@ -717,7 +619,7 @@ export function AnnotationSettingsPanel({
</div>
</div>
</div>
<div className="flex-shrink-0 border-t border-foreground/10 bg-editor-panel p-4 pt-3">
<div className="shrink-0 px-5 py-4">
<Button
onClick={onDelete}
variant="destructive-soft"
@@ -1,5 +1,5 @@
import { DownloadSimple as Download, FilmSlate as Film, Image } from "@phosphor-icons/react";
import { Card, Label, Description, ToggleButtonGroup, ToggleButton } from "@heroui/react";
import { Card, Label, Description, TagGroup, Tag } from "@heroui/react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
@@ -51,37 +51,45 @@ function Choices<T extends string | number>({
}: {
label: string;
value: T;
options: { value: T; label: ReactNode; description?: string }[];
options: { value: T; label: ReactNode; textValue?: string; description?: string }[];
onChange?: (value: T) => void;
}) {
return (
<div className="flex flex-col gap-2">
<Label>{label}</Label>
<ToggleButtonGroup
<TagGroup
aria-label={label}
selectionMode="single"
disallowEmptySelection
selectedKeys={[String(value)]}
onSelectionChange={(keys) => {
if (keys === "all") return;
const selected = options.find((option) => keys.has(String(option.value)));
if (selected) onChange?.(selected.value);
}}
fullWidth
size="sm"
size="lg"
>
{options.map((option) => (
<ToggleButton
key={option.value}
id={String(option.value)}
className="h-auto min-h-9 flex-1 flex-col gap-0.5 py-2"
>
{option.label}
{option.description && (
<span className="text-[10px] opacity-70">{option.description}</span>
)}
</ToggleButton>
))}
</ToggleButtonGroup>
<TagGroup.List className="flex gap-2">
{options.map((option) => (
<Tag
key={option.value}
id={String(option.value)}
textValue={
option.textValue ??
(typeof option.label === "string"
? option.label
: String(option.value))
}
className="h-auto min-h-10 flex-1 justify-center flex-col gap-0.5 py-2"
>
{option.label}
{option.description && (
<span className="text-[10px] opacity-70">{option.description}</span>
)}
</Tag>
))}
</TagGroup.List>
</TagGroup>
</div>
);
}
@@ -128,6 +136,7 @@ export function ExportSettingsMenu({
options={[
{
value: "mp4",
textValue: tSettings("export.mp4"),
label: (
<span className="flex items-center gap-2">
<Film />
@@ -137,6 +146,7 @@ export function ExportSettingsMenu({
},
{
value: "gif",
textValue: tSettings("export.gif"),
label: (
<span className="flex items-center gap-2">
<Image />
@@ -170,7 +180,12 @@ export function ExportSettingsMenu({
onChange={onExportEncodingModeChange}
options={(["fast", "balanced", "quality"] as const).map((value) => ({
value,
label: tSettings(`export.encoding.${value}`),
label: tSettings(
`export.encoding.${value}`,
{ fast: "Fast", balanced: "Balanced", quality: "Quality" }[
value
],
),
}))}
/>
<Choices
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,7 @@ export const SliderControl = memo(function SliderControl({
maxValue={max}
step={step}
onChange={(value) => onChange(Number(value))}
className="w-full"
className="my-1.5 w-full gap-y-2"
>
<Label>{label}</Label>
<Slider.Output>{() => formatValue(value)}</Slider.Output>
+1 -1
View File
@@ -31,7 +31,7 @@ const CONTACT_EMAIL = "youngchen3442@gmail.com";
export const APP_HEADER_ACTION_BUTTON_CLASS =
"h-7 px-2 text-xs text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all gap-1.5";
export const APP_HEADER_ICON_BUTTON_CLASS =
"h-7 w-7 p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all";
"h-9 w-9 min-w-9 p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all";
interface KeyboardShortcutsDialogProps {
triggerLabel?: string;
+23 -2
View File
@@ -1,5 +1,5 @@
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: setters returned by the editor's domain-state hooks are stable React dispatchers. */
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { getAspectRatioValue } from "@/utils/aspectRatioUtils";
@@ -92,7 +92,28 @@ export default function VideoEditor() {
applySessionPresentation,
} = ui;
const effectiveShowCursor = sessionShowCursorOverride ?? showCursor;
const headerLeftControlsPaddingClass = appPlatform === "darwin" ? "pl-[76px]" : "";
const [trafficLightsVisible, setTrafficLightsVisible] = useState(false);
useEffect(() => {
let active = true;
let receivedEvent = false;
const unsubscribe = window.electronAPI.onWindowChromeChanged?.((chrome) => {
receivedEvent = true;
if (active) setTrafficLightsVisible(chrome.trafficLightsVisible);
});
void window.electronAPI
.getWindowChrome?.()
.then((chrome) => {
if (active && !receivedEvent) setTrafficLightsVisible(chrome.trafficLightsVisible);
})
.catch(() => {
/* Window controls are absent in renderer-only previews. */
});
return () => {
active = false;
unsubscribe?.();
};
}, []);
const headerLeftControlsPaddingClass = trafficLightsVisible ? "pl-[76px]" : "";
const { cursorTelemetrySourcePath, autoCaptions, autoCaptionSettings } = timeline;
const exportSettings = useExportSettings(
initialEditorPreferences,
@@ -104,7 +104,7 @@ export function EditorExportMenu(props: Props) {
<PopoverTrigger asChild>
<Button
type="button"
className="inline-flex h-8 min-w-[112px] items-center justify-center gap-2 px-4.5"
className="inline-flex h-9 min-w-[104px] items-center justify-center gap-2 px-4.5"
>
<Download className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">
@@ -92,12 +92,12 @@ export function EditorHeader(props: Props) {
} = project;
return (
<div
className="relative z-50 flex h-11 flex-shrink-0 items-center justify-between border-b border-separator bg-surface px-4"
<header
className="editor-header relative z-50 grid h-14 shrink-0 bg-surface grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,1fr)] items-center gap-3 px-4"
style={{ WebkitAppRegion: "drag" } as CSSProperties}
>
<div
className={`flex items-center justify-self-start gap-1.5 ${headerLeftControlsPaddingClass}`}
className={`editor-header-start flex items-center justify-self-start gap-1 ${headerLeftControlsPaddingClass}`}
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
<Button
@@ -112,15 +112,17 @@ export function EditorHeader(props: Props) {
>
<FolderOpen className="h-4 w-4" />
</Button>
<DiscordLinkButton />
<FeedbackDialog />
<div className="ml-1 h-5 w-px bg-foreground/10" />
<div className="editor-header-community flex items-center gap-1">
<DiscordLinkButton />
<FeedbackDialog />
</div>
<div className="w-2 shrink-0" />
<Button
type="button"
variant="ghost"
onClick={handleUndo}
disabled={!canUndo}
className="inline-flex h-8 w-8 items-center justify-center p-0 disabled:cursor-not-allowed"
className="inline-flex h-9 w-9 min-w-9 items-center justify-center p-0 disabled:cursor-not-allowed"
title={t("common.actions.undo", "Undo")}
aria-label={t("common.actions.undo", "Undo")}
>
@@ -131,7 +133,7 @@ export function EditorHeader(props: Props) {
variant="ghost"
onClick={handleRedo}
disabled={!canRedo}
className="inline-flex h-8 w-8 items-center justify-center p-0 disabled:cursor-not-allowed"
className="inline-flex h-9 w-9 min-w-9 items-center justify-center p-0 disabled:cursor-not-allowed"
title={t("common.actions.redo", "Redo")}
aria-label={t("common.actions.redo", "Redo")}
>
@@ -140,16 +142,16 @@ export function EditorHeader(props: Props) {
</div>
<div
className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center"
className="editor-header-title flex min-w-0 items-center justify-center"
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
{isEditingProjectName ? (
<form
onSubmit={(event) => void handleProjectNameSubmit(event)}
className="flex max-w-[min(52vw,460px)] items-baseline gap-1 rounded-[7px] border border-foreground/10 bg-editor-panel/[0.88] px-2.5 py-1 shadow-[0_10px_28px_rgba(0,0,0,0.18)]"
className="flex w-full min-w-0 items-center gap-1"
>
{hasUnsavedChanges ? (
<span className="mt-[1px] size-2 shrink-0 rounded-full bg-[#2563EB]" />
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
) : null}
<Input
ref={projectNameInputRef}
@@ -166,11 +168,10 @@ export function EditorHeader(props: Props) {
}
}}
disabled={isSavingProjectName}
className="min-w-[10ch] max-w-[min(40vw,360px)] text-sm disabled:cursor-wait"
style={{ width: `${Math.max(projectNameDraft.length, 10)}ch` }}
className="min-w-0 w-full text-sm disabled:cursor-wait"
aria-label={t("editor.project.renameInput", "Project name")}
/>
<span className="shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
<span className="project-file-extension shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
.recordly
</span>
</form>
@@ -179,17 +180,17 @@ export function EditorHeader(props: Props) {
variant="ghost"
type="button"
onClick={() => setIsEditingProjectName(true)}
className="inline-flex max-w-[min(52vw,460px)] items-baseline gap-1 px-2.5 py-1"
className="inline-flex h-9 min-w-0 max-w-full items-center gap-1.5 px-3"
title={t("editor.project.renameTitle", "Rename project")}
aria-label={t("editor.project.renameTitle", "Rename project")}
>
{hasUnsavedChanges ? (
<span className="mt-[1px] size-2 shrink-0 rounded-full bg-[#2563EB]" />
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
) : null}
<span className="truncate text-sm font-semibold tracking-tight text-foreground/90">
{projectDisplayName}
</span>
<span className="shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
<span className="project-file-extension shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
.recordly
</span>
</Button>
@@ -197,14 +198,10 @@ export function EditorHeader(props: Props) {
</div>
<div
className="flex items-center justify-self-end"
className="editor-header-end flex min-w-0 items-center justify-self-end gap-3"
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
<EditorPresetMenu t={t} presets={presets} />
<div
aria-hidden="true"
className="mx-2 h-4 w-px shrink-0 bg-foreground/10 opacity-0"
/>
<EditorExportMenu
t={t}
exportSettings={exportSettings}
@@ -224,6 +221,6 @@ export function EditorHeader(props: Props) {
exportMessage={exportMessage}
/>
</div>
</div>
</header>
);
}
@@ -32,11 +32,11 @@ export function EditorPresetMenu({ t, presets }: Props) {
type="button"
title={t("editor.presets.open", "Open presets")}
aria-label={t("editor.presets.open", "Open presets")}
className="inline-flex items-center gap-1.5 p-0 text-sm"
className="inline-flex h-9 min-w-0 max-w-40 items-center gap-2 px-3 text-sm"
>
<span className="flex items-center gap-1.5">
<span className="flex min-w-0 items-center gap-2">
<BookmarkSimple weight="fill" className="h-4 w-4" />
<span>
<span className="truncate">
{currentEditorPreset?.name ?? t("editor.presets.label", "Presets")}
</span>
</span>
@@ -117,7 +117,7 @@ export function EditorPreviewPanel(props: Props) {
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex h-11 flex-shrink-0 items-center justify-center gap-2 border-b border-separator bg-surface">
<div className="flex h-14 shrink-0 items-center justify-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -164,12 +164,15 @@ export function EditorPreviewPanel(props: Props) {
className="flex min-h-0 w-full flex-1 items-stretch px-6 py-5"
style={{ flex: "1 1 auto", margin: 0 }}
>
<div className="flex min-w-0 flex-1 items-center justify-center px-1">
<div
className="editor-preview-stage flex min-h-0 min-w-0 flex-1 items-center justify-center"
style={{ containerType: "size" }}
>
<div
className="relative"
className="editor-preview-frame relative"
style={{
width: "auto",
height: "100%",
width: `min(100cqw, calc(100cqh * ${previewAspectRatioValue}))`,
height: `min(100cqh, calc(100cqw / ${previewAspectRatioValue}))`,
aspectRatio: previewAspectRatioValue,
maxWidth: "100%",
margin: "0 auto",
@@ -213,11 +216,11 @@ export function EditorPreviewPanel(props: Props) {
</div>
</div>
<div className="relative flex h-12 flex-shrink-0 items-center border-t border-separator bg-surface px-4">
<div className="z-10 flex min-w-0 flex-1 items-center gap-1.5">
<div className="editor-playback relative grid min-h-14 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3 px-4">
<div className="editor-playback-tools z-10 flex min-w-0 items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 gap-1 px-2.5">
<Button variant="ghost" size="sm" className="h-9 gap-2 px-3">
<Plus className="h-3.5 w-3.5" />
<span className="font-medium">{t("editor.toolbar.addLayer")}</span>
<CaretDown className="h-3 w-3" />
@@ -258,12 +261,11 @@ export function EditorPreviewPanel(props: Props) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="mx-1 h-4 w-px bg-foreground/10" />
<Button
onClick={() => timelineRef.current?.addZoom()}
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-9 w-9"
title={t("timeline.zoom.addZoom")}
>
<MagnifyingGlassPlus className="h-4 w-4" />
@@ -272,7 +274,7 @@ export function EditorPreviewPanel(props: Props) {
onClick={() => timelineRef.current?.suggestZooms()}
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-9 w-9"
title={t("timeline.zoom.suggestZooms")}
>
<MagicWand className="h-4 w-4" />
@@ -281,14 +283,14 @@ export function EditorPreviewPanel(props: Props) {
onClick={() => timelineRef.current?.splitClip()}
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-9 w-9"
title={t("editor.toolbar.splitClip")}
>
<Scissors className="h-4 w-4" />
</Button>
</div>
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<div className="editor-playback-center z-10 flex items-center justify-center">
<div className="pointer-events-auto flex items-center gap-1.5">
<span className="mr-1 text-[10px] font-medium tabular-nums text-muted-foreground">
{formatTime(projection.timelinePlayheadTime)}
@@ -296,7 +298,7 @@ export function EditorPreviewPanel(props: Props) {
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-9 w-9"
title={t("editor.playback.skipBack")}
onClick={playback.handlePreviewSkipBack}
>
@@ -305,7 +307,7 @@ export function EditorPreviewPanel(props: Props) {
<Button
variant="ghost"
size="icon"
className={`h-7 w-7 ${isPlaying ? "bg-foreground/10 text-foreground hover:bg-foreground/20" : "bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"} `}
className={`h-9 w-9 ${isPlaying ? "bg-foreground/10 text-foreground hover:bg-foreground/20" : "bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"} `}
onClick={playback.togglePlayPause}
title={isPlaying ? "Pause" : "Play"}
>
@@ -318,7 +320,7 @@ export function EditorPreviewPanel(props: Props) {
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-9 w-9"
title={t("editor.playback.skipForward")}
onClick={playback.handlePreviewSkipForward}
>
@@ -330,7 +332,7 @@ export function EditorPreviewPanel(props: Props) {
</div>
</div>
<div className="z-10 ml-auto flex items-center gap-2">
<div className="editor-playback-volume z-10 ml-auto flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
@@ -182,7 +182,7 @@ export function EditorShell(props: Props) {
/>
<EditorAnnouncementBanner />
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="relative z-10 flex min-h-0 flex-1">
<div className="relative z-10 flex min-h-0 flex-1 pt-3">
<EditorSidebar
t={t}
activeSection={ui.activeEffectSection}
@@ -7,10 +7,10 @@ import {
FrameCorners,
UserCircle,
} from "@phosphor-icons/react";
import { ToggleButtonGroup, ToggleButton, Tooltip } from "@heroui/react";
import { ToggleButtonGroup, ToggleButton, Tooltip, Card, Switch, Label } from "@heroui/react";
import { Button } from "@/components/ui/button";
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { toast } from "@/components/ui/toast";
import type { useI18n } from "@/contexts/I18nContext";
import ExtensionManager from "../ExtensionManager";
@@ -25,6 +25,13 @@ type Props = {
};
export function EditorSidebar({ t, activeSection, setActiveSection, settingsPanelProps }: Props) {
const [advancedSections, setAdvancedSections] = useState<Record<string, boolean>>({});
const advanced = advancedSections[activeSection] ?? false;
const hasAdvanced =
!settingsPanelProps.selectedAnnotationId &&
["scene", "frame", "crop", "cursor", "webcam", "captions", "settings", "zoom"].includes(
activeSection,
);
const sections = useMemo(
() => [
{
@@ -53,10 +60,10 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane
[t],
);
return (
<div className="flex min-h-0 flex-shrink-0 border-r border-separator bg-surface">
<div className="flex min-h-0 shrink-0 gap-2 pb-3 pr-2">
<nav
aria-label={t("settings.sections.title", "Editor tools")}
className="flex w-14 flex-col items-center gap-3 border-r border-separator py-3"
className="flex w-14 flex-col items-center gap-3 py-2.5"
>
<ToggleButtonGroup
orientation="vertical"
@@ -97,22 +104,46 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane
</Button>
</nav>
<aside className="flex w-[320px] min-h-0 flex-col">
<header className="flex h-11 shrink-0 items-center border-b border-separator px-4">
<h2 className="text-sm font-medium">
{settingsPanelProps.selectedAnnotationId
? t("timeline.annotation.label", "Annotation")
: (sections.find((section) => section.id === activeSection)?.label ??
t(
`settings.sections.${activeSection}`,
activeSection.charAt(0).toUpperCase() + activeSection.slice(1),
))}
</h2>
</header>
{activeSection === "extensions" ? (
<ExtensionManager />
) : (
<SettingsPanel {...settingsPanelProps} />
)}
<Card className="min-h-0 flex-1 gap-0 overflow-hidden p-0">
<header className="flex min-h-14 shrink-0 items-center justify-between gap-3 px-5 py-3">
<Card.Title>
{settingsPanelProps.selectedAnnotationId
? t("timeline.annotation.label", "Annotation")
: (sections.find((section) => section.id === activeSection)
?.label ??
t(
`settings.sections.${activeSection}`,
activeSection.charAt(0).toUpperCase() +
activeSection.slice(1),
))}
</Card.Title>
{hasAdvanced && (
<Switch
size="sm"
isSelected={advanced}
onChange={(value) =>
setAdvancedSections((current) => ({
...current,
[activeSection]: value,
}))
}
aria-label="Advanced settings"
>
<Switch.Content>
<Label>Advanced</Label>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch.Content>
</Switch>
)}
</header>
{activeSection === "extensions" ? (
<ExtensionManager />
) : (
<SettingsPanel {...settingsPanelProps} advanced={advanced} />
)}
</Card>
</aside>
</div>
);
@@ -58,7 +58,7 @@ export function EditorTimelinePanel(props: Props) {
return (
<div
className="flex flex-shrink-0 flex-col border-t border-separator bg-surface px-3 pb-3 pt-1"
className="flex flex-shrink-0 flex-col bg-transparent px-4 pb-4 pt-2"
style={{ height: "22%", minHeight: 180, maxHeight: 280 }}
>
<TimelineEditor
@@ -419,10 +419,10 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
}
return (
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col bg-transparent overflow-hidden">
<div
ref={timelineContainerRef}
className="flex-1 min-h-0 overflow-auto bg-editor-bg relative"
className="flex-1 min-h-0 overflow-auto bg-transparent relative"
tabIndex={0}
onFocus={() => {
isTimelineFocusedRef.current = true;
+38
View File
@@ -88,3 +88,41 @@
scrollbar-color: var(--scrollbar-thumb) transparent;
}
}
/* Responsive geometry belongs to the editor, leaving HeroUI control styles intact. */
.editor-header-start,
.editor-header-end {
white-space: nowrap;
}
.editor-playback {
container-type: inline-size;
}
@media (max-width: 1000px) {
.editor-header {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.4fr) minmax(0, 1fr);
}
.project-file-extension {
display: none;
}
}
@container (max-width: 760px) {
.editor-playback-tools {
grid-row: 2;
grid-column: 1 / -1;
justify-content: center;
padding-bottom: 8px;
}
.editor-playback-center {
grid-row: 1;
grid-column: 2;
}
.editor-playback-volume {
grid-row: 1;
grid-column: 3;
}
}
@container (max-width: 520px) {
.editor-playback-volume .slider {
display: none;
}
}
+8
View File
@@ -20,6 +20,14 @@ export async function installDesktopBridge(page: Page) {
}),
getAccessibilityPermissionStatus: async () => ({ success: true, trusted: true }),
getPlatform: async () => "darwin",
getWindowChrome: async () => ({ trafficLightsVisible: true }),
onWindowChromeChanged: (
callback: (chrome: { trafficLightsVisible: boolean }) => void,
) => {
const listener = (event: Event) => callback((event as CustomEvent).detail);
window.addEventListener("test-window-chrome", listener);
return () => window.removeEventListener("test-window-chrome", listener);
},
getAppVersion: async () => "1.4.0",
getAnnouncements: async () => ({ success: true, announcements: [] }),
loadCurrentProjectFile: async () => ({ success: false }),
+119
View File
@@ -0,0 +1,119 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("advanced controls preserve values and remember each section's view", async ({ page }) => {
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
const advanced = page.getByRole("switch", { name: "Advanced settings" });
await expect(advanced).toBeVisible({ timeout: 20000 });
await expect(page.getByRole("switch", { name: "Link padding sides" })).toHaveCount(0);
const blur = page.getByRole("slider", { name: "Blur", exact: true });
await blur.focus();
await page.keyboard.press("ArrowRight");
const value = await blur.inputValue();
await page.locator("aside").getByText("Advanced", { exact: true }).click();
await expect(page.getByRole("switch", { name: "Link padding sides" })).toBeVisible();
await page.getByRole("radio", { name: "Webcam", exact: true }).click();
await expect(advanced).not.toBeChecked();
await expect(page.getByRole("slider", { name: "Webcam Height" })).toHaveCount(0);
await page.locator("aside").getByText("Advanced", { exact: true }).click();
await expect(page.getByRole("slider", { name: "Webcam Height" })).toBeAttached();
await page.screenshot({
path: "test-results/editor-webcam-advanced.png",
animations: "disabled",
});
await page.getByRole("radio", { name: "Scene", exact: true }).click();
await expect(advanced).toBeChecked();
await expect(blur).toHaveValue(value);
await page.locator("aside").getByText("Advanced", { exact: true }).click();
await expect(blur).toHaveValue(value);
await page.getByRole("row", { name: "Color", exact: true }).click();
await page.getByRole("button", { name: "Custom color", exact: true }).click();
await page.getByRole("textbox", { name: "Hex color", exact: true }).fill("#27AE60");
await page.keyboard.press("Tab");
await expect(page.getByRole("textbox", { name: "Hex color", exact: true })).toHaveValue(
"#27AE60",
);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Custom color", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Hex color", exact: true })).toHaveValue(
"#27AE60",
);
await page.screenshot({ path: "test-results/editor-color-picker.png", animations: "disabled" });
});
test("header stays centered with long names, native chrome and compact windows", async ({
page,
}) => {
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible({
timeout: 20000,
});
await page.getByRole("button", { name: "Rename project" }).click();
await page
.getByRole("textbox", { name: "Project name" })
.fill("A very long project title that should stay centered and never cover the toolbar");
// Validate the editing state as well as the display state.
for (const width of [1440, 1280, 800]) {
await page.setViewportSize({ width, height: 800 });
for (const trafficLightsVisible of [true, false]) {
await page.evaluate(
(visible) =>
window.dispatchEvent(
new CustomEvent("test-window-chrome", {
detail: { trafficLightsVisible: visible },
}),
),
trafficLightsVisible,
);
await expect(page.locator(".editor-header-start")).toHaveCSS(
"padding-left",
trafficLightsVisible ? "76px" : "0px",
);
const stage = await page.locator(".editor-preview-stage").boundingBox();
const frame = await page.locator(".editor-preview-frame").boundingBox();
expect(
Math.abs(stage!.x + stage!.width / 2 - frame!.x - frame!.width / 2),
).toBeLessThan(1);
expect(
Math.abs(stage!.y + stage!.height / 2 - frame!.y - frame!.height / 2),
).toBeLessThan(1);
const boxes = await Promise.all(
[".editor-header-start", ".editor-header-title", ".editor-header-end"].map(
(selector) => page.locator(selector).boundingBox(),
),
);
const [left, center, right] = boxes;
expect(Math.abs(center!.x + center!.width / 2 - width / 2)).toBeLessThan(1);
expect(left!.x + left!.width).toBeLessThanOrEqual(center!.x);
expect(center!.x + center!.width).toBeLessThanOrEqual(right!.x);
const buttons = await page.locator(".editor-playback button").evaluateAll((nodes) =>
nodes
.filter((n) => n.getBoundingClientRect().width > 0)
.map((n) => {
const r = n.getBoundingClientRect();
return { x: r.x, y: r.y, right: r.right, bottom: r.bottom };
}),
);
for (let i = 0; i < buttons.length; i++)
for (let j = i + 1; j < buttons.length; j++) {
const a = buttons[i],
b = buttons[j];
expect(
a.right <= b.x || b.right <= a.x || a.bottom <= b.y || b.bottom <= a.y,
).toBe(true);
}
await expect(
page.getByRole("button", { name: "Export", exact: true }),
).toBeInViewport();
await expect(page.getByRole("button", { name: "Play", exact: true })).toBeInViewport();
}
await page.screenshot({
path: `test-results/editor-layout-${width}.png`,
animations: "disabled",
});
}
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible();
});
+9 -6
View File
@@ -1,13 +1,16 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("editor loads video, switches tools and edits export options", async ({ page }) => {
test.setTimeout(60000);
const errors: string[] = [];
page.on("pageerror", (e) => {
errors.push(e.message);
});
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
await expect(page.getByRole("navigation", { name: "Editor tools" })).toBeVisible();
await expect(page.getByRole("navigation", { name: "Editor tools" })).toBeVisible({
timeout: 20000,
});
await expect(page.getByRole("slider", { name: "Blur", exact: true })).toBeVisible();
await expect
.poll(() =>
@@ -24,7 +27,7 @@ test("editor loads video, switches tools and edits export options", async ({ pag
animations: "disabled",
});
const inspectorWidth = (await page.locator("aside").boundingBox())?.width;
await page.getByRole("radio", { name: "Color", exact: true }).click();
await page.getByRole("row", { name: "Color", exact: true }).click();
await expect(page.getByRole("button", { name: "Custom color", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Custom color", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Custom color", exact: true })).toBeVisible();
@@ -34,19 +37,19 @@ test("editor loads video, switches tools and edits export options", async ({ pag
await page.getByRole("radio", { name: "Captions", exact: true }).click();
await page.getByRole("radio", { name: "Settings", exact: true }).click();
await expect(page.getByText("Appearance", { exact: true })).toBeVisible();
await page.getByRole("radio", { name: "Dark", exact: true }).click();
await page.getByRole("row", { name: "Dark", exact: true }).click();
await expect(page.locator("html")).toHaveClass(/dark/);
await page.screenshot({
path: "test-results/editor-dark.png",
fullPage: true,
animations: "disabled",
});
await page.getByRole("radio", { name: "Light", exact: true }).click();
await page.getByRole("row", { name: "Light", exact: true }).click();
await expect(page.locator("html")).not.toHaveClass(/dark/);
await page.getByRole("radio", { name: "Scene", exact: true }).click();
await page.getByRole("button", { name: "Export", exact: true }).click();
await expect(page.getByRole("radiogroup", { name: "Format", exact: true })).toBeVisible();
await page.getByRole("radio", { name: "GIF", exact: true }).click();
await expect(page.getByRole("grid", { name: "Format", exact: true })).toBeVisible();
await page.getByRole("row", { name: "GIF", exact: true }).click();
await expect(page.getByRole("switch", { name: "Loop", exact: false })).toBeVisible();
await page.screenshot({
path: "test-results/editor-export-light.png",