Restore timeline palette and refine compact wallpaper controls

This commit is contained in:
webadderall committed 2026-09-19 17:08:40 +10:00
1 parent c5b54bf8f8
commit 35511bf3e0
10 files changed
+599 -299

No files matched your search

+7 -3
View File
@@ -37,8 +37,11 @@ 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
surface instead of nesting cards and shadows. Timeline blocks retain the original
Recordly palette in both themes. Inspector controls use compact 12–13px text and
32–36px controls, with 14px section titles. Image and video wallpaper grids share
a plus tile for importing and a small remove control on custom tiles, revealed
on hover or keyboard focus. The recorder keeps its compact
desktop layout.
The adapters in `src/components/ui` translate existing Recordly state/callback
@@ -64,7 +67,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, Advanced state, color editing, and header/playback
windows, theme switching, Advanced state, color editing, wallpaper uploads and
keyboard/hover removal, and header/playback
alignment from 800–1440px with and without macOS window controls. Screenshots and failure
traces go to the ignored `test-results/` directory.
+2 -2
View File
@@ -20,7 +20,7 @@ export function ChoiceGroup({
}) {
return (
<TagGroup
size="lg"
size="md"
aria-label={props["aria-label"]}
selectionMode="single"
disallowEmptySelection
@@ -58,7 +58,7 @@ export function ChoiceItem({
title ??
(typeof children === "string" ? children : props["aria-label"])
}
className={`min-h-9 justify-center ${className ?? ""}`}
className={`min-h-8 justify-center ${className ?? ""}`}
>
{children}
</Tag>
+1 -1
View File
@@ -58,7 +58,7 @@ export function ColorControl({
<Button
variant="secondary"
aria-label={label}
className="h-10 min-w-0 max-w-full gap-2 px-3"
className="h-9 min-w-0 max-w-full gap-2 px-3 text-[13px]"
>
<ColorSwatch size="sm" />
<span className="truncate">
+71 -223
View File
@@ -1,8 +1,7 @@
import { ToggleButton } from "@heroui/react";
import { Card, RadioGroup, Radio, Label, Description } from "@heroui/react";
import { ProgressBar } from "@heroui/react";
import { ColorControl, ColorPalette } from "@/components/ui/color-picker";
import { Palette, Trash as Trash2, UploadSimple as Upload, X } from "@phosphor-icons/react";
import { Palette, Trash as Trash2, UploadSimple as Upload } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "@/components/ui/toast";
@@ -41,6 +40,7 @@ import {
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
import { getDefaultBorderRadiusPercent } from "./projectPersistence";
import { SliderControl } from "./SliderControl";
import { WallpaperGrid } from "./WallpaperGrid";
import { KeyboardShortcutsDialog } from "./TutorialHelp";
import type {
AnnotationRegion,
@@ -185,49 +185,7 @@ function getBackgroundTabForWallpaper(value: string): BackgroundTab {
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return <Label>{children}</Label>;
}
function WallpaperVideoPreview({ src }: { src: string }) {
const [resolvedSrc, setResolvedSrc] = useState(src);
useEffect(() => {
let cancelled = false;
setResolvedSrc(src);
void (async () => {
try {
const nextSrc = await getRenderableVideoUrl(src);
if (!cancelled) {
setResolvedSrc(nextSrc);
}
} catch {
if (!cancelled) {
setResolvedSrc(src);
}
}
})();
return () => {
cancelled = true;
};
}, [src]);
return (
<video
src={resolvedSrc}
muted
playsInline
preload="metadata"
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
draggable={false}
onMouseEnter={(e) => e.currentTarget.play().catch(() => undefined)}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/>
);
return <Label className="text-[13px]">{children}</Label>;
}
const MOTION_PRESET_ORDER: CursorMotionPresetId[] = ["focused", "smooth"];
@@ -1386,44 +1344,6 @@ export function SettingsPanel({
isSelected && "ring-2 ring-accent",
);
const renderWallpaperImageTile = (
wallpaperUrl: string,
isSelected: boolean,
props?: {
key?: string;
ariaLabel?: string;
title?: string;
onClick?: () => void;
children?: React.ReactNode;
},
) => (
<ToggleButton
isSelected={isSelected}
key={props?.key}
className={wallpaperTileClass(isSelected)}
aria-label={props?.ariaLabel}
onClick={props?.onClick}
>
<div className="absolute inset-px overflow-hidden rounded-[5px]">
{isVideoWallpaperSource(wallpaperUrl) ? (
<WallpaperVideoPreview src={wallpaperUrl} />
) : (
<img
src={wallpaperUrl}
alt={
props?.title ??
props?.ariaLabel ??
tSettings("background.wallpaperPreview", "Wallpaper preview")
}
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
draggable={false}
/>
)}
</div>
{props?.children}
</ToggleButton>
);
const crop = cropRegion ?? {
x: 0,
y: 0,
@@ -1654,8 +1574,7 @@ export function SettingsPanel({
}
};
const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => {
event.stopPropagation();
const handleRemoveCustomImage = (imageUrl: string) => {
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
// If the removed image was selected, clear selection
if (selected === imageUrl) {
@@ -1733,8 +1652,8 @@ export function SettingsPanel({
exit={{ opacity: 0, y: -8, filter: "blur(6px)" }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{backgroundTab === "image" ? (
<div className="mt-0 space-y-4">
{backgroundTab === "image" || backgroundTab === "video" ? (
<>
<input
type="file"
ref={fileInputRef}
@@ -1742,126 +1661,49 @@ export function SettingsPanel({
accept=".jpg,.jpeg,image/jpeg"
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
className="w-full gap-2 h-9"
>
<Upload className="w-3 h-3" />
{tSettings("background.uploadCustom")}
</Button>
<div className="grid grid-cols-5 gap-2">
{customImages.map((imageUrl, idx) => {
const isSelected = getWallpaperTileState(imageUrl);
return renderWallpaperImageTile(imageUrl, isSelected, {
key: `custom-${idx}`,
ariaLabel: isVideoWallpaperSource(imageUrl)
? (imageUrl.split(/[\\/]/).pop() ??
tSettings(
"background.video",
"Video background",
))
: undefined,
title: isVideoWallpaperSource(imageUrl)
? imageUrl.split(/[\\/]/).pop()
: undefined,
onClick: () => onWallpaperChange(imageUrl),
children: (
<Button
variant="destructive-soft"
onClick={(e) =>
handleRemoveCustomImage(imageUrl, e)
}
className="absolute top-0.5 right-0.5 w-3 h-3 flex items-center justify-center z-10"
>
<X className="w-2 h-2 text-white" />
</Button>
),
});
})}
{imageWallpaperTiles.map((tile) => {
const isSelected = getWallpaperTileState(
tile.value,
tile.previewUrl,
);
return renderWallpaperImageTile(
tile.previewUrl,
isSelected,
{
key: tile.key,
ariaLabel: tile.label,
title: tile.label,
onClick: () => onWallpaperChange(tile.value),
},
);
})}
</div>
</div>
) : backgroundTab === "video" ? (
<div className="mt-0 space-y-4">
<Button
onClick={handleVideoUpload}
variant="outline"
className="w-full gap-2 h-9"
>
<Upload className="w-3 h-3" />
{tSettings("background.uploadCustomVideo", "Upload Video")}
</Button>
<div className="grid grid-cols-5 gap-2">
{customImages
.filter(isVideoWallpaperSource)
.map((videoUrl, idx) => {
const isSelected = getWallpaperTileState(videoUrl);
return renderWallpaperImageTile(
videoUrl,
isSelected,
{
key: `custom-video-${idx}`,
ariaLabel:
videoUrl.split(/[\\/]/).pop() ??
"Video background",
title: videoUrl.split(/[\\/]/).pop(),
onClick: () => onWallpaperChange(videoUrl),
children: (
<Button
variant="destructive-soft"
onClick={(e) =>
handleRemoveCustomImage(
videoUrl,
e,
)
}
className="absolute top-0.5 right-0.5 w-3 h-3 flex items-center justify-center z-10"
>
<X className="w-2 h-2 text-white" />
</Button>
),
},
);
})}
{videoWallpaperTiles.map((wallpaper) => {
const isSelected = getWallpaperTileState(
wallpaper.value,
wallpaper.previewUrl,
);
return renderWallpaperImageTile(
wallpaper.previewUrl,
isSelected,
{
key: wallpaper.key,
ariaLabel: wallpaper.label,
title: wallpaper.label,
onClick: () =>
onWallpaperChange(wallpaper.value),
},
);
})}
</div>
</div>
<WallpaperGrid
addLabel={
backgroundTab === "image"
? tSettings(
"background.addWallpaper",
"Add wallpaper",
)
: tSettings(
"background.addVideoWallpaper",
"Add video wallpaper",
)
}
onAdd={
backgroundTab === "image"
? () => fileInputRef.current?.click()
: handleVideoUpload
}
onSelect={onWallpaperChange}
onRemove={handleRemoveCustomImage}
isSelected={getWallpaperTileState}
items={[
...customImages
.filter(
(url) =>
isVideoWallpaperSource(url) ===
(backgroundTab === "video"),
)
.map((url, index) => ({
key: `custom-${index}`,
value: url,
previewUrl: url,
label: isVideoWallpaperSource(url)
? (url.split(/[\\/]/).pop() ??
"Custom video wallpaper")
: `${tSettings("background.customWallpaper", "Custom wallpaper")} ${index + 1}`,
removable: true,
})),
...(backgroundTab === "image"
? imageWallpaperTiles
: videoWallpaperTiles),
]}
/>
</>
) : backgroundTab === "color" ? (
<div className="mt-0 space-y-4">
<div className="flex flex-col gap-3">
@@ -2165,6 +2007,8 @@ export function SettingsPanel({
<div className="flex items-center gap-3">
<SectionLabel>{tSettings("captions.generation", "Generation")}</SectionLabel>
<Button
className="text-xs text-muted"
size="sm"
variant="ghost"
type="button"
onClick={() => onAutoCaptionSettingsChange?.(DEFAULT_AUTO_CAPTION_SETTINGS)}
@@ -2188,7 +2032,7 @@ export function SettingsPanel({
type="button"
variant="outline"
onClick={onPickWhisperModel}
className="h-10 w-full px-4 text-sm"
className="h-9 w-full px-4 text-sm"
>
{tSettings("captions.selectModel", "Select Model")}
</Button>
@@ -2201,7 +2045,7 @@ export function SettingsPanel({
value={autoCaptionSettings.language || "auto"}
onValueChange={(value) => updateAutoCaptionSettings({ language: value })}
>
<SelectTrigger className="h-10 w-[180px] text-sm">
<SelectTrigger className="h-9 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -2216,7 +2060,7 @@ export function SettingsPanel({
<div className="flex flex-wrap items-center gap-2">
<div className="grid w-full grid-cols-2 gap-2">
{whisperModelDownloadStatus === "downloading" ? (
<Button type="button" disabled className="h-10 w-full px-4 text-sm">
<Button type="button" disabled className="h-9 w-full px-4 text-sm">
{tSettings("captions.downloading", "Downloading...")}{" "}
{Math.round(whisperModelDownloadProgress)}%
</Button>
@@ -2225,7 +2069,7 @@ export function SettingsPanel({
type="button"
variant="outline"
onClick={onDeleteWhisperSmallModel}
className="h-10 w-full px-4 text-sm"
className="h-9 w-full px-4 text-sm"
>
{tSettings("captions.deleteModel", "Delete Model")}
</Button>
@@ -2233,7 +2077,7 @@ export function SettingsPanel({
<Button
type="button"
onClick={onDownloadWhisperSmallModel}
className="h-10 w-full px-4 text-sm"
className="h-9 w-full px-4 text-sm"
>
{tSettings("captions.downloadModel", "Download Model")}
</Button>
@@ -2243,7 +2087,7 @@ export function SettingsPanel({
variant="outline"
onClick={onClearAutoCaptions}
disabled={captionCueCount === 0}
className="h-10 w-full px-4 text-sm"
className="h-9 w-full px-4 text-sm"
>
{tSettings("captions.clearFull", "Clear Captions")}
</Button>
@@ -2254,7 +2098,7 @@ export function SettingsPanel({
type="button"
onClick={onGenerateAutoCaptions}
disabled={isGeneratingCaptions || !whisperModelPath}
className="h-10 w-full px-4 text-sm"
className="h-9 w-full px-4 text-sm"
>
{isGeneratingCaptions
? tSettings("captions.generating", "Generating...")
@@ -2460,7 +2304,7 @@ export function SettingsPanel({
<section className="flex flex-col gap-4">
<SectionLabel>{t("common.app.language", "Language")}</SectionLabel>
<Select value={locale} onValueChange={(value) => setLocale(value as AppLocale)}>
<SelectTrigger className="h-10 w-full text-sm">
<SelectTrigger className="h-9 w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -2564,7 +2408,7 @@ export function SettingsPanel({
<SectionLabel>{t("editor.keyboardShortcuts.title")}</SectionLabel>
<KeyboardShortcutsDialog
triggerLabel={t("editor.keyboardShortcuts.customize")}
triggerClassName="h-10 w-full justify-start rounded-xl border border-foreground/10 bg-foreground/5 px-3 text-sm text-foreground hover:bg-foreground/10 hover:text-foreground"
triggerClassName="h-9 w-full justify-start rounded-xl border border-foreground/10 bg-foreground/5 px-3 text-sm text-foreground hover:bg-foreground/10 hover:text-foreground"
/>
</section>
@@ -2854,7 +2698,7 @@ export function SettingsPanel({
}}
variant="destructive-soft"
size="sm"
className="mt-1 h-10 w-full gap-2 text-xs"
className="mt-1 h-9 w-full gap-2 text-xs"
>
<Trash2 className="h-3 w-3" />
{tSettings("zoom.deleteZoom")}
@@ -2868,6 +2712,8 @@ export function SettingsPanel({
<div className="flex items-center justify-between gap-3">
<SectionLabel>{tSettings("audio.volumeTitle", "Audio")}</SectionLabel>
<Button
className="text-xs text-muted"
size="sm"
variant="ghost"
type="button"
onClick={() => {
@@ -3304,6 +3150,8 @@ export function SettingsPanel({
{tSettings("effects.webcamCrop", "Crop")}
</div>
<Button
className="text-xs text-muted"
size="sm"
variant="ghost"
type="button"
onClick={() =>
@@ -3475,7 +3323,7 @@ export function SettingsPanel({
type="button"
variant="outline"
onClick={onUploadWebcam}
className="h-10 min-w-0 gap-2 px-3"
className="h-9 min-w-0 gap-2 px-3"
>
<Upload className="h-3 w-3" />
<span className="min-w-0 truncate">
@@ -3489,7 +3337,7 @@ export function SettingsPanel({
type="button"
variant="outline"
onClick={onClearWebcam}
className="h-10 min-w-0 gap-2 px-3"
className="h-9 min-w-0 gap-2 px-3"
>
<Trash2 className="h-3 w-3" />
<span className="min-w-0 truncate">
@@ -3547,7 +3395,7 @@ export function SettingsPanel({
}}
variant="destructive-soft"
size="sm"
className="h-10 w-full gap-2 text-xs"
className="h-9 w-full gap-2 text-xs"
>
<Trash2 className="h-3 w-3" />
{tSettings("clip.delete", "Delete Clip")}
@@ -3560,7 +3408,7 @@ export function SettingsPanel({
}}
variant="destructive-soft"
size="sm"
className="h-10 w-full gap-2 text-xs"
className="h-9 w-full gap-2 text-xs"
>
<Trash2 className="h-3 w-3" />
{tSettings("zoom.deleteZoom", "Delete Zoom")}
@@ -3573,7 +3421,7 @@ export function SettingsPanel({
}}
variant="destructive-soft"
size="sm"
className="h-10 w-full gap-2 text-xs"
className="h-9 w-full gap-2 text-xs"
>
<Trash2 className="h-3 w-3" />
{tSettings("audio.deleteRegion", "Delete Audio")}
@@ -3587,7 +3435,7 @@ export function SettingsPanel({
}}
variant="destructive-soft"
size="sm"
className="h-10 w-full gap-2 text-xs"
className="h-9 w-full gap-2 text-xs"
>
<Trash2 className="h-3 w-3" />
{tSettings("annotation.delete", "Delete Annotation")}
@@ -31,10 +31,12 @@ export const SliderControl = memo(function SliderControl({
maxValue={max}
step={step}
onChange={(value) => onChange(Number(value))}
className="my-1.5 w-full gap-y-2"
className="my-1 w-full gap-y-2"
>
<Label>{label}</Label>
<Slider.Output>{() => formatValue(value)}</Slider.Output>
<Label className="text-xs">{label}</Label>
<Slider.Output className="text-xs font-normal">
{() => formatValue(value)}
</Slider.Output>
<Slider.Track>
<Slider.Fill />
<Slider.Thumb />
@@ -0,0 +1,125 @@
import { Button, ToggleButton } from "@heroui/react";
import { Plus, X } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { getRenderableVideoUrl } from "@/lib/assetPath";
import { isVideoWallpaperSource } from "@/lib/wallpapers";
import { cn } from "@/lib/utils";
interface WallpaperTile {
key: string;
value: string;
previewUrl: string;
label: string;
removable?: boolean;
}
/** Shared image/video gallery. Removal stays separate from the selection button. */
export function WallpaperGrid({
items,
addLabel,
onAdd,
onSelect,
onRemove,
isSelected,
}: {
items: WallpaperTile[];
addLabel: string;
onAdd: () => void;
onSelect: (value: string) => void;
onRemove: (value: string) => void;
isSelected: (value: string, previewUrl?: string) => boolean;
}) {
return (
<div className="grid grid-cols-5 gap-2">
<Button
variant="secondary"
isIconOnly
aria-label={addLabel}
onPress={onAdd}
className="aspect-[4/3] h-auto w-full min-w-0 rounded-md p-0 text-muted"
>
<Plus className="size-4" />
</Button>
{items.map((item) => {
const selected = isSelected(item.value, item.previewUrl);
return (
<div key={item.key} className="group relative flex min-w-0">
<ToggleButton
isSelected={selected}
aria-label={item.label}
onChange={() => onSelect(item.value)}
className={cn(
"relative aspect-[4/3] h-auto w-full min-w-0 overflow-hidden rounded-md p-0",
selected && "ring-2 ring-accent ring-offset-2 ring-offset-surface",
)}
>
{isVideoWallpaperSource(item.previewUrl) ? (
<WallpaperVideoPreview src={item.previewUrl} />
) : (
<img
src={item.previewUrl}
alt=""
draggable={false}
className="absolute inset-0 h-full w-full select-none object-cover"
/>
)}
</ToggleButton>
{item.removable && (
<Button
variant="secondary"
isIconOnly
aria-label={`Remove ${item.label}`}
onPress={() => onRemove(item.value)}
className="absolute right-0.5 top-0.5 z-10 size-5 min-w-0 rounded-full p-0 bg-overlay text-foreground shadow-sm opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100"
>
<X className="size-3" />
</Button>
)}
</div>
);
})}
</div>
);
}
function WallpaperVideoPreview({ src }: { src: string }) {
const [resolvedSrc, setResolvedSrc] = useState(src);
useEffect(() => {
let cancelled = false;
setResolvedSrc(src);
void (async () => {
try {
const nextSrc = await getRenderableVideoUrl(src);
if (!cancelled) {
setResolvedSrc(nextSrc);
}
} catch {
if (!cancelled) {
setResolvedSrc(src);
}
}
})();
return () => {
cancelled = true;
};
}, [src]);
return (
<video
src={resolvedSrc}
muted
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full select-none object-cover"
draggable={false}
onMouseEnter={(e) => e.currentTarget.play().catch(() => undefined)}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/>
);
}
@@ -93,7 +93,7 @@ export function EditorHeader(props: Props) {
return (
<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"
className="editor-header [--text-sm:0.8125rem] relative z-50 grid h-14 shrink-0 border-b border-separator grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,1fr)] items-center gap-3 px-4"
style={{ WebkitAppRegion: "drag" } as CSSProperties}
>
<div
@@ -116,7 +116,7 @@ export function EditorHeader(props: Props) {
<DiscordLinkButton />
<FeedbackDialog />
</div>
<div className="w-2 shrink-0" />
<div className="mx-2 h-4 w-px shrink-0 bg-separator" />
<Button
type="button"
variant="ghost"
@@ -187,7 +187,7 @@ export function EditorHeader(props: Props) {
{hasUnsavedChanges ? (
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
) : null}
<span className="truncate text-sm font-semibold tracking-tight text-foreground/90">
<span className="truncate text-[13px] font-medium tracking-tight text-foreground/90">
{projectDisplayName}
</span>
<span className="project-file-extension shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
@@ -103,10 +103,10 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane
<UserCircle />
</Button>
</nav>
<aside className="flex w-[320px] min-h-0 flex-col">
<aside className="editor-inspector [--text-sm:0.8125rem] [--text-base:0.8125rem] flex w-[320px] min-h-0 flex-col">
<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>
<Card.Title className="text-[14px]">
{settingsPanelProps.selectedAnnotationId
? t("timeline.annotation.label", "Annotation")
: (sections.find((section) => section.id === activeSection)
@@ -130,7 +130,7 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane
aria-label="Advanced settings"
>
<Switch.Content>
<Label>Advanced</Label>
<Label className="text-xs">Advanced</Label>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
@@ -1,68 +1,179 @@
/* Custom timeline geometry, using the same palette as the HeroUI controls. */
.glassGreen,
.glassRed,
.glassYellow,
.glassAmber,
.glassPurple,
.glassCyan,
.glassDarkGreen,
.glassCaption {
position: relative;
border-radius: 6px;
background: color-mix(in oklab, var(--region-accent) 14%, var(--surface));
border: 1px solid color-mix(in oklab, var(--region-accent) 25%, var(--separator));
transition:
background-color 150ms ease,
border-color 150ms ease;
}
.glassGreen {
--region-accent: var(--accent);
position: relative;
border-radius: 8px;
background: #183065;
border: 1px solid #1b3a7f;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.glassRed {
--region-accent: var(--danger);
.glassGreen:hover {
background: #1a387a;
border-color: #1e469f;
}
.glassYellow {
--region-accent: var(--warning);
}
.glassAmber {
--region-accent: var(--warning);
}
.glassPurple {
--region-accent: var(--color-purple-500);
}
.glassCyan {
--region-accent: var(--accent);
}
.glassDarkGreen {
--region-accent: var(--success);
}
.glassCaption {
--region-accent: var(--color-pink-500);
}
.glassGreen:hover,
.glassRed:hover,
.glassYellow:hover,
.glassAmber:hover,
.glassPurple:hover,
.glassCyan:hover,
.glassDarkGreen:hover,
.glassCaption:hover {
background: color-mix(in oklab, var(--region-accent) 20%, var(--surface));
border-color: var(--region-accent);
}
.glassGreen.selected,
.glassRed.selected,
.glassYellow.selected,
.glassAmber.selected,
.glassPurple.selected,
.glassCyan.selected,
.glassDarkGreen.selected,
.glassCaption.selected {
background: color-mix(in oklab, var(--region-accent) 22%, var(--surface));
border-color: var(--region-accent);
box-shadow: inset 0 0 0 1px var(--region-accent);
.glassGreen.selected {
background: #1c3e89;
border-color: #2563eb;
box-shadow: inset 0 0 0 1.5px #2563eb;
z-index: 10;
}
.glassRed {
position: relative;
border-radius: 8px;
background: #652425;
border: 1px solid #802a2b;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.glassRed:hover {
background: #7b292a;
border-color: #a13232;
}
.glassRed.selected {
background: #8b2d2d;
border-color: #ef4444;
box-shadow: inset 0 0 0 1.5px #ef4444;
z-index: 10;
}
.glassYellow {
position: relative;
border-radius: 8px;
background: #4e4726;
border: 1px solid #62582c;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.glassYellow:hover {
background: #5f552b;
border-color: #7a6d34;
}
.glassYellow.selected {
background: #6a5f2f;
border-color: #b4a046;
box-shadow: inset 0 0 0 1.5px #b4a046;
z-index: 10;
}
.glassAmber {
position: relative;
border-radius: 8px;
background: #67460f;
border: 1px solid #83570f;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.glassAmber:hover {
background: #7e540f;
border-color: #a56c0d;
}
.glassAmber.selected {
background: #8e5e0e;
border-color: #f59e0b;
box-shadow: inset 0 0 0 1.5px #f59e0b;
z-index: 10;
}
.glassPurple {
position: relative;
border-radius: 8px;
background: #4a2a69;
border: 1px solid #5c3385;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.glassPurple:hover {
background: #593180;
border-color: #733da7;
}
.glassPurple.selected {
background: #643690;
border-color: #a855f7;
box-shadow: inset 0 0 0 1.5px #a855f7;
z-index: 10;
}
.glassCyan {
position: relative;
border-radius: 8px;
background: #183065;
border: 1px solid #1b3a7f;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.glassCyan:hover {
background: #1a387a;
border-color: #1e469f;
}
.glassCyan.selected {
background: #1c3e89;
border-color: #2563eb;
box-shadow: inset 0 0 0 1.5px #2563eb;
z-index: 10;
}
.glassDarkGreen {
position: relative;
border-radius: 8px;
background: #164f2d;
border: 1px solid #196b38;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.glassDarkGreen:hover {
background: #186234;
border-color: #1c8643;
}
.glassDarkGreen.selected {
background: #196e3a;
border-color: #22c55e;
box-shadow: inset 0 0 0 1.5px #22c55e;
z-index: 10;
}
.glassCaption {
position: relative;
border-radius: 8px;
background: #5b2746;
border: 1px solid #7a3157;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.glassCaption:hover {
background: #6d2f54;
border-color: #933a68;
}
.glassCaption.selected {
background: #7a3157;
border-color: #f472b6;
box-shadow: inset 0 0 0 1.5px #f472b6;
z-index: 10;
}
/* Pill resize handle — visible on hover */
.zoomEndCap {
position: absolute;
@@ -78,12 +189,136 @@
justify-content: center;
transform: translateY(-50%);
border-radius: 2px;
background: color-mix(in oklab, var(--foreground) 70%, transparent);
background: rgba(255, 255, 255, 0.88);
transition:
opacity 0.15s ease,
background 0.15s ease;
}
/* --- Light-mode overrides --- */
:global(:root:not(.dark)) .glassGreen {
background: linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%);
border-color: #60a5fa;
}
:global(:root:not(.dark)) .glassGreen:hover {
background: linear-gradient(180deg, #bfdbfe 0%, #60a5fa 100%);
border-color: #3b82f6;
}
:global(:root:not(.dark)) .glassGreen.selected {
background: linear-gradient(180deg, #93c5fd 0%, #3b82f6 100%);
border-color: #2563eb;
box-shadow: inset 0 0 0 1.5px #2563eb;
}
:global(:root:not(.dark)) .glassRed {
background: linear-gradient(180deg, #fee2e2 0%, #fca5a5 100%);
border-color: #f87171;
}
:global(:root:not(.dark)) .glassRed:hover {
background: linear-gradient(180deg, #fecaca 0%, #f87171 100%);
border-color: #ef4444;
}
:global(:root:not(.dark)) .glassRed.selected {
background: linear-gradient(180deg, #fca5a5 0%, #ef4444 100%);
border-color: #ef4444;
box-shadow: inset 0 0 0 1.5px #ef4444;
}
:global(:root:not(.dark)) .glassYellow {
background: linear-gradient(180deg, #fef9c3 0%, #fde047 100%);
border-color: #facc15;
}
:global(:root:not(.dark)) .glassYellow:hover {
background: linear-gradient(180deg, #fef08a 0%, #facc15 100%);
border-color: #eab308;
}
:global(:root:not(.dark)) .glassYellow.selected {
background: linear-gradient(180deg, #fde047 0%, #eab308 100%);
border-color: #b4a046;
box-shadow: inset 0 0 0 1.5px #b4a046;
}
:global(:root:not(.dark)) .glassAmber {
background: linear-gradient(180deg, #fef3c7 0%, #fcd34d 100%);
border-color: #fbbf24;
}
:global(:root:not(.dark)) .glassAmber:hover {
background: linear-gradient(180deg, #fde68a 0%, #fbbf24 100%);
border-color: #f59e0b;
}
:global(:root:not(.dark)) .glassAmber.selected {
background: linear-gradient(180deg, #fcd34d 0%, #f59e0b 100%);
border-color: #f59e0b;
box-shadow: inset 0 0 0 1.5px #f59e0b;
}
:global(:root:not(.dark)) .glassPurple {
background: linear-gradient(180deg, #f3e8ff 0%, #d8b4fe 100%);
border-color: #c084fc;
}
:global(:root:not(.dark)) .glassPurple:hover {
background: linear-gradient(180deg, #e9d5ff 0%, #c084fc 100%);
border-color: #a855f7;
}
:global(:root:not(.dark)) .glassPurple.selected {
background: linear-gradient(180deg, #d8b4fe 0%, #a855f7 100%);
border-color: #a855f7;
box-shadow: inset 0 0 0 1.5px #a855f7;
}
:global(:root:not(.dark)) .glassCyan {
background: linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%);
border-color: #60a5fa;
}
:global(:root:not(.dark)) .glassCyan:hover {
background: linear-gradient(180deg, #bfdbfe 0%, #60a5fa 100%);
border-color: #3b82f6;
}
:global(:root:not(.dark)) .glassCyan.selected {
background: linear-gradient(180deg, #93c5fd 0%, #3b82f6 100%);
border-color: #2563eb;
box-shadow: inset 0 0 0 1.5px #2563eb;
}
:global(:root:not(.dark)) .glassDarkGreen {
background: linear-gradient(180deg, #dcfce7 0%, #86efac 100%);
border-color: #4ade80;
}
:global(:root:not(.dark)) .glassDarkGreen:hover {
background: linear-gradient(180deg, #bbf7d0 0%, #4ade80 100%);
border-color: #22c55e;
}
:global(:root:not(.dark)) .glassDarkGreen.selected {
background: linear-gradient(180deg, #86efac 0%, #22c55e 100%);
border-color: #22c55e;
box-shadow: inset 0 0 0 1.5px #22c55e;
}
:global(:root:not(.dark)) .glassCaption {
background: linear-gradient(180deg, #fce7f3 0%, #f9a8d4 100%);
border-color: #f472b6;
}
:global(:root:not(.dark)) .glassCaption:hover {
background: linear-gradient(180deg, #fbcfe8 0%, #f472b6 100%);
border-color: #ec4899;
}
:global(:root:not(.dark)) .glassCaption.selected {
background: linear-gradient(180deg, #f9a8d4 0%, #ec4899 100%);
border-color: #ec4899;
box-shadow: inset 0 0 0 1.5px #ec4899;
}
:global(:root:not(.dark)) .zoomEndCap {
background: rgba(0, 0, 0, 0.5);
}
:global(:root:not(.dark)) .zoomEndCap:hover {
background: rgba(0, 0, 0, 0.7);
}
.zoomEndCap:hover {
background: rgba(255, 255, 255, 1);
}
.glassGreen:hover .zoomEndCap,
.glassRed:hover .zoomEndCap,
.glassYellow:hover .zoomEndCap,
+86
View File
@@ -0,0 +1,86 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("wallpaper tiles add, select and remove custom backgrounds without nested controls", async ({
page,
}) => {
test.setTimeout(60000);
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
const add = page.getByRole("button", { name: "Add wallpaper", exact: true });
await expect(add).toBeVisible({ timeout: 20000 });
await expect(page.getByRole("button", { name: "Upload Custom", exact: true })).toHaveCount(0);
const initialSelection = page.locator('aside button[aria-pressed="true"]');
const originalName = await initialSelection.getAttribute("aria-label");
const chooser = page.waitForEvent("filechooser");
await add.click();
await (await chooser).setFiles("public/wallpapers/wallpaper1.jpg");
const custom = page.getByRole("button", { name: "Custom wallpaper 1", exact: true });
const remove = page.getByRole("button", { name: "Remove Custom wallpaper 1", exact: true });
await expect(custom).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("aside button button")).toHaveCount(0);
await page.getByRole("button", { name: "Rename project" }).hover();
await expect(remove).toHaveCSS("opacity", "0");
await custom.hover();
await expect(remove).toHaveCSS("opacity", "1");
await page.screenshot({
path: "test-results/editor-wallpaper-hover.png",
animations: "disabled",
});
// Removing an unselected tile must preserve the current background.
const original = page.getByRole("button", { name: originalName!, exact: true });
await original.click();
await custom.hover();
await remove.click();
await expect(custom).toHaveCount(0);
await expect(original).toHaveAttribute("aria-pressed", "true");
// The same file can be added again and removed with the keyboard.
const secondChooser = page.waitForEvent("filechooser");
await add.click();
await (await secondChooser).setFiles("public/wallpapers/wallpaper1.jpg");
await expect(custom).toHaveAttribute("aria-pressed", "true");
await remove.focus();
await expect(remove).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
await expect(custom).toHaveCount(0);
await expect(original).toHaveAttribute("aria-pressed", "true");
await page.reload({ waitUntil: "domcontentloaded" });
await expect(add).toBeVisible({ timeout: 20000 });
await expect(custom).toHaveCount(0);
});
test("video wallpapers share the gallery's add and remove controls", async ({ page }) => {
test.setTimeout(60000);
await installDesktopBridge(page);
await page.goto("/?windowType=editor");
const original = page.locator('aside button[aria-pressed="true"]');
await expect(original).toBeVisible({ timeout: 20000 });
const originalName = await original.getAttribute("aria-label");
await page.getByRole("row", { name: "Video", exact: true }).click();
await expect(
page.getByRole("button", { name: "Add video wallpaper", exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Upload Video", exact: true })).toHaveCount(0);
await page.evaluate(() => {
window.electronAPI.openVideoFilePicker = async () => ({
success: true,
path: `${location.origin}/tests/ui/fixtures/preview.mp4`,
});
});
await page.getByRole("button", { name: "Add video wallpaper", exact: true }).click();
const video = page.getByRole("button", { name: "preview.mp4", exact: true });
await expect(video).toHaveAttribute("aria-pressed", "true");
const videoTile = await video.boundingBox();
expect(videoTile!.height).toBeLessThan(50);
await video.hover();
await page.screenshot({
path: "test-results/editor-video-wallpaper-hover.png",
animations: "disabled",
});
await page.getByRole("button", { name: "Remove preview.mp4", exact: true }).click();
await expect(video).toHaveCount(0);
await expect(page.getByRole("button", { name: originalName!, exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
});