Restore timeline palette and refine compact wallpaper controls

This commit is contained in:
webadderall
2026-09-19 17:08:40 +10:00
parent 3931f23e05
commit fc89e1d42e
10 changed files with 599 additions and 299 deletions
+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,