feat: UI polish sprint — 7 items + logs toolbar redesign (#365)

* feat: UI polish sprint — tag filters, toast tokens, logs toolbar, billing portal, editor UX

- Fleet: replace inline tag pills with multi-select combobox dropdown
- Toast: remap all notification colors to oklch design tokens
- Logs: convert floating hover toolbar to permanent pinned toolbar,
  replace all hardcoded colors with design tokens for theme support
- Audit: fix dropdown scroll-lock (modal=false), light theme button contrast
- Resources: remove redundant inner border/bg on tab wrapper
- Editor: add ⌘K/Ctrl+K shortcut hint and handler, standardize button heights
- Billing: signed Lemon Squeezy portal URLs via sencho.io proxy for all tiers
- New MultiSelectCombobox UI component

* feat(editor): replace button row with split-button dropdown

Replace three separate editor buttons (Discard, Save Only, Save & Deploy)
with a compact split-button dropdown. Primary action is "Save & Deploy";
chevron opens dropdown with "Save Only" and "Discard Changes" options.
This commit is contained in:
Anso
2026-04-03 20:33:44 -04:00
committed by GitHub
parent 2a277eb09d
commit f9ebd1d77c
13 changed files with 417 additions and 105 deletions
@@ -0,0 +1,159 @@
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
export interface MultiSelectOption {
value: string
label: string
color?: string
}
interface MultiSelectComboboxProps {
options: MultiSelectOption[]
selected: Set<string>
onSelectionChange: (selected: Set<string>) => void
placeholder?: string
searchPlaceholder?: string
emptyText?: string
disabled?: boolean
className?: string
renderOption?: (option: MultiSelectOption, isSelected: boolean) => React.ReactNode
}
export function MultiSelectCombobox({
options,
selected,
onSelectionChange,
placeholder = "Select...",
searchPlaceholder = "Search...",
emptyText = "No results found.",
disabled = false,
className,
renderOption,
}: MultiSelectComboboxProps) {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState("")
const wrapperRef = React.useRef<HTMLDivElement>(null)
const filtered = search
? options.filter((o) =>
o.label.toLowerCase().includes(search.toLowerCase())
)
: options
React.useEffect(() => {
if (!open) return
const onMouseDown = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false)
setSearch("")
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation()
setOpen(false)
setSearch("")
}
}
document.addEventListener("mousedown", onMouseDown)
document.addEventListener("keydown", onKeyDown, true)
return () => {
document.removeEventListener("mousedown", onMouseDown)
document.removeEventListener("keydown", onKeyDown, true)
}
}, [open])
const handleToggle = (option: MultiSelectOption) => {
const next = new Set(selected)
if (next.has(option.value)) {
next.delete(option.value)
} else {
next.add(option.value)
}
onSelectionChange(next)
}
const triggerLabel = selected.size > 0
? `${selected.size} tag${selected.size !== 1 ? 's' : ''}`
: placeholder
return (
<div ref={wrapperRef} className={cn("relative", className)}>
<button
type="button"
role="combobox"
aria-expanded={open}
disabled={disabled}
onClick={() => { if (!disabled) setOpen(!open) }}
className={cn(
"flex h-7 items-center gap-1.5 whitespace-nowrap rounded-md border border-input bg-transparent px-2.5 text-xs shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 transition-colors",
selected.size > 0 ? "text-foreground" : "text-muted-foreground",
open && "ring-1 ring-ring border-ring"
)}
>
<span>{triggerLabel}</span>
<ChevronsUpDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
{open && (
<div className="absolute left-0 top-[calc(100%+4px)] z-50 min-w-[180px] rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] animate-in fade-in-0 zoom-in-95 slide-in-from-top-2">
{options.length > 5 && (
<div className="p-1.5 border-b border-glass-border">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
className="h-7 w-full bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
autoFocus
/>
</div>
)}
<div className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
{filtered.length === 0 ? (
<div className="py-3 text-center text-xs text-muted-foreground">
{emptyText}
</div>
) : (
filtered.map((option) => {
const isSelected = selected.has(option.value)
return (
<button
key={option.value}
type="button"
onClick={() => handleToggle(option)}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent/50"
)}
>
<Check
className={cn(
"mr-2 h-3.5 w-3.5 shrink-0",
isSelected ? "opacity-100" : "opacity-0"
)}
strokeWidth={1.5}
/>
{renderOption ? renderOption(option, isSelected) : option.label}
</button>
)
})
)}
</div>
{selected.size > 0 && (
<div className="border-t border-glass-border p-1">
<button
type="button"
onClick={() => onSelectionChange(new Set())}
className="flex w-full items-center justify-center rounded-sm px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
Clear all
</button>
</div>
)}
</div>
)}
</div>
)
}
+13 -13
View File
@@ -54,24 +54,24 @@ const notificationConfig: Record<ToastType, {
gradient: string;
}> = {
info: {
iconColor: 'text-blue-500 dark:text-blue-400',
iconColor: 'text-info',
icon: <InfoIcon className="h-6 w-6" />,
gradient: 'from-blue-100/60 to-transparent dark:from-blue-900/20 dark:to-transparent',
gradient: 'from-info-muted to-transparent',
},
success: {
iconColor: 'text-green-500 dark:text-green-400',
iconColor: 'text-success',
icon: <SuccessIcon className="h-6 w-6" />,
gradient: 'from-green-100/60 to-transparent dark:from-green-900/20 dark:to-transparent',
gradient: 'from-success-muted to-transparent',
},
warning: {
iconColor: 'text-yellow-500 dark:text-yellow-400',
iconColor: 'text-warning',
icon: <WarningIcon className="h-6 w-6" />,
gradient: 'from-yellow-100/60 to-transparent dark:from-yellow-900/20 dark:to-transparent',
gradient: 'from-warning-muted to-transparent',
},
error: {
iconColor: 'text-red-500 dark:text-red-400',
iconColor: 'text-destructive',
icon: <ErrorIcon className="h-6 w-6" />,
gradient: 'from-red-100/60 to-transparent dark:from-red-900/20 dark:to-transparent',
gradient: 'from-destructive-muted to-transparent',
},
};
@@ -113,7 +113,7 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100 }}
transition={{ duration: 0.3 }}
className="relative w-full max-w-sm rounded-xl p-4 backdrop-blur-xl bg-white/15 dark:bg-black/15 border border-gray-300/60 dark:border-gray-700/60 overflow-hidden ring-1 ring-gray-200/40 dark:ring-gray-700/40 drop-shadow-xl transition-all duration-300 ease-in-out transform hover:scale-105 font-[family-name:var(--font-sans)]"
className="relative w-full max-w-sm rounded-xl p-4 backdrop-blur-xl bg-card/80 border border-glass-border overflow-hidden ring-1 ring-glass-border drop-shadow-xl transition-all duration-300 ease-in-out transform hover:scale-105 font-[family-name:var(--font-sans)]"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
@@ -126,11 +126,11 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
{config.icon}
</div>
<div className="flex-1">
<p className="font-normal text-gray-900 dark:text-gray-100 text-lg">{message}</p>
<p className="font-normal text-foreground text-lg">{message}</p>
</div>
<button
onClick={dismiss}
className="flex-shrink-0 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"
className="flex-shrink-0 text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-full hover:bg-accent"
aria-label="Close notification"
>
<CloseIcon className="h-5 w-5" />
@@ -138,12 +138,12 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
</div>
{/* Progress bar — Sera UI style with Framer Motion */}
<div className="absolute bottom-0 left-0 h-1 w-full bg-gray-300/50 dark:bg-gray-600/50 rounded-b-xl overflow-hidden">
<div className="absolute bottom-0 left-0 h-1 w-full bg-glass-border rounded-b-xl overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: hovered ? undefined : '100%' }}
transition={{ duration: duration / 1000, ease: 'linear' }}
className="h-full bg-gradient-to-r from-green-400 via-blue-400 to-sky-400 dark:from-green-500 dark:via-blue-500 dark:to-sky-500"
className="h-full bg-gradient-to-r from-success via-info to-brand"
/>
</div>
</motion.div>