feat(desktop): long-press a rail profile to pick its color

Hold (~450ms) a profile square — or right-click → Color… — to open a
shadcn Popover of swatches and override its rail color, with Auto to fall
back to the deterministic hue. The hold timer rides alongside the dnd
pointer listener (a real drag cancels it, the trailing click is
suppressed), so reorder/select/recolor stay distinct gestures.

Overrides persist in localStorage ($profileColors), resolved via
resolveProfileColor (override wins, else the name-hashed hue). Cosmetic
and gated on the multi-profile rail, so single-profile users are
unaffected. Adds a reusable ui/popover.tsx (radix-ui umbrella).
This commit is contained in:
Brooklyn Nicholson
2026-06-04 20:12:37 -05:00
parent 86371e6cd8
commit 1b01fa3acf
5 changed files with 287 additions and 55 deletions

View File

@ -25,12 +25,14 @@ import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { profileColor, profileColorSoft } from '@/lib/profile-color' import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
$activeGatewayProfile, $activeGatewayProfile,
$profileColors,
$profileOrder, $profileOrder,
$profiles, $profiles,
$profileScope, $profileScope,
@ -38,6 +40,7 @@ import {
normalizeProfileKey, normalizeProfileKey,
refreshActiveProfile, refreshActiveProfile,
selectProfile, selectProfile,
setProfileColor,
setProfileOrder, setProfileOrder,
setShowAllProfiles, setShowAllProfiles,
sortByProfileOrder sortByProfileOrder
@ -85,6 +88,7 @@ export function ProfileRail() {
const scope = useStore($profileScope) const scope = useStore($profileScope)
const gatewayProfile = useStore($activeGatewayProfile) const gatewayProfile = useStore($activeGatewayProfile)
const order = useStore($profileOrder) const order = useStore($profileOrder)
const colors = useStore($profileColors)
const navigate = useNavigate() const navigate = useNavigate()
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
@ -215,10 +219,11 @@ export function ProfileRail() {
{named.map(profile => ( {named.map(profile => (
<ProfileSquare <ProfileSquare
active={!isAll && normalizeProfileKey(profile.name) === activeKey} active={!isAll && normalizeProfileKey(profile.name) === activeKey}
color={profileColor(profile.name)} color={resolveProfileColor(profile.name, colors)}
key={profile.name} key={profile.name}
label={profile.name} label={profile.name}
onDelete={() => setPendingDelete(profile)} onDelete={() => setPendingDelete(profile)}
onRecolor={color => setProfileColor(profile.name, color)}
onRename={() => setPendingRename(profile)} onRename={() => setPendingRename(profile)}
onSelect={() => selectProfile(profile.name)} onSelect={() => selectProfile(profile.name)}
/> />
@ -306,10 +311,15 @@ interface ProfileSquareProps {
color: null | string color: null | string
label: string label: string
onSelect: () => void onSelect: () => void
onRecolor: (color: null | string) => void
onRename: () => void onRename: () => void
onDelete: () => void onDelete: () => void
} }
// Hold this long without moving (a drag would have started first) to open the
// color picker — the "hard press" gesture, distinct from tap-to-select.
const LONG_PRESS_MS = 450
// A profile *is* its colored square — no icon-button chrome. Soft profile-tint // A profile *is* its colored square — no icon-button chrome. Soft profile-tint
// fill + the initial in the full color; the active one pops to full opacity with // fill + the initial in the full color; the active one pops to full opacity with
// a color ring. These pack tightly so the rail reads as a strip of profiles, // a color ring. These pack tightly so the rail reads as a strip of profiles,
@ -317,71 +327,165 @@ interface ProfileSquareProps {
// right-click to rename/delete. The button carries both the tooltip and // right-click to rename/delete. The button carries both the tooltip and
// context-menu triggers via nested asChild Slots, so a single element keeps the // context-menu triggers via nested asChild Slots, so a single element keeps the
// dnd listeners, hover tip, and right-click menu. // dnd listeners, hover tip, and right-click menu.
function ProfileSquare({ active, color, label, onSelect, onRename, onDelete }: ProfileSquareProps) { function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
const hue = color ?? 'var(--ui-text-quaternary)' const hue = color ?? 'var(--ui-text-quaternary)'
const [pickerOpen, setPickerOpen] = useState(false)
const pressTimer = useRef<null | number>(null)
const suppressClick = useRef(false)
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
id: label, id: label,
transition: RAIL_TRANSITION transition: RAIL_TRANSITION
}) })
const clearPress = () => {
if (pressTimer.current != null) {
clearTimeout(pressTimer.current)
pressTimer.current = null
}
}
// A real drag (movement past the dnd threshold) cancels the pending hold, so a
// reorder never doubles as a color pick. Also tidy up on unmount.
useEffect(() => {
if (isDragging) {
clearPress()
}
}, [isDragging])
useEffect(() => clearPress, [])
const base = CSS.Transform.toString(transform) const base = CSS.Transform.toString(transform)
const ring = active ? `inset 0 0 0 1.5px ${hue}` : '' const ring = active ? `inset 0 0 0 1.5px ${hue}` : ''
const lift = isDragging ? '0 6px 16px -4px rgb(0 0 0 / 0.4)' : '' const lift = isDragging ? '0 6px 16px -4px rgb(0 0 0 / 0.4)' : ''
return ( const pickColor = (next: null | string) => {
<ContextMenu> onRecolor(next)
<TooltipProvider delayDuration={0}> setPickerOpen(false)
<Tooltip> triggerHaptic('selection')
<ContextMenuTrigger asChild> }
<TooltipTrigger asChild>
<button
className={cn(
'grid size-5 shrink-0 cursor-grab touch-none select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
active ? 'opacity-100' : 'opacity-55',
isDragging && 'z-10 cursor-grabbing opacity-100'
)}
onClick={onSelect}
ref={setNodeRef}
style={{
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
boxShadow: [ring, lift].filter(Boolean).join(', ') || undefined,
color: color ?? undefined,
// Glide the dragged square between snapped cells with a little
// overshoot (no scale — the overflow-x strip would clip it).
transform: base,
transition: isDragging ? DRAG_TRANSITION : transition
}}
type="button"
{...attributes}
{...listeners}
aria-label={label}
aria-pressed={active}
>
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</button>
</TooltipTrigger>
</ContextMenuTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* The rail sits at the very bottom, so pad off the chrome (esp. the return (
statusbar) — Radix then flips the menu up instead of squishing it. */} <Popover onOpenChange={setPickerOpen} open={pickerOpen}>
<ContextMenuContent <ContextMenu>
aria-label={`Actions for ${label}`} <TooltipProvider delayDuration={0}>
className="w-40" <Tooltip>
<PopoverAnchor asChild>
<ContextMenuTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
'grid size-5 shrink-0 cursor-grab touch-none select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
active ? 'opacity-100' : 'opacity-55',
isDragging && 'z-10 cursor-grabbing opacity-100'
)}
ref={setNodeRef}
style={{
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
boxShadow: [ring, lift].filter(Boolean).join(', ') || undefined,
color: color ?? undefined,
// Glide the dragged square between snapped cells with a little
// overshoot (no scale — the overflow-x strip would clip it).
transform: base,
transition: isDragging ? DRAG_TRANSITION : transition
}}
type="button"
{...attributes}
{...listeners}
aria-label={label}
aria-pressed={active}
// Hold-to-recolor rides alongside the dnd pointer listener (call
// it first so drag tracking still arms), then a timer opens the
// picker and flags the trailing click so it doesn't also select.
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false
return
}
onSelect()
}}
onPointerCancel={clearPress}
onPointerDown={event => {
listeners?.onPointerDown?.(event)
if (event.button !== 0) {
return
}
suppressClick.current = false
clearPress()
pressTimer.current = window.setTimeout(() => {
suppressClick.current = true
triggerHaptic('success')
setPickerOpen(true)
}, LONG_PRESS_MS)
}}
onPointerLeave={clearPress}
onPointerUp={clearPress}
>
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</button>
</TooltipTrigger>
</ContextMenuTrigger>
</PopoverAnchor>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
statusbar) — Radix then flips the menu up instead of squishing it. */}
<ContextMenuContent
aria-label={`Actions for ${label}`}
className="w-40"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
>
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />
<span>Color</span>
</ContextMenuItem>
<ContextMenuItem onSelect={onRename}>
<Codicon name="edit" size="0.875rem" />
<span>Rename</span>
</ContextMenuItem>
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<PopoverContent
aria-label={`Color for ${label}`}
className="w-auto p-2"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }} collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
side="top"
> >
<ContextMenuItem onSelect={onRename}> <div className="grid grid-cols-6 gap-1.5">
<Codicon name="edit" size="0.875rem" /> {PROFILE_SWATCHES.map(swatch => (
<span>Rename</span> <button
</ContextMenuItem> aria-label={`Set color ${swatch}`}
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive"> className="size-5 rounded-full transition-transform hover:scale-110"
<Codicon name="trash" size="0.875rem" /> key={swatch}
<span>Delete</span> onClick={() => pickColor(swatch)}
</ContextMenuItem> style={{
</ContextMenuContent> backgroundColor: swatch,
</ContextMenu> boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
color: swatch
}}
type="button"
/>
))}
</div>
<button
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => pickColor(null)}
type="button"
>
<Codicon name="sync" size="0.75rem" />
Auto
</button>
</PopoverContent>
</Popover>
) )
} }

View File

@ -0,0 +1,44 @@
import { Popover as PopoverPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverContent({
align = 'center',
className,
collisionPadding = 8,
sideOffset = 6,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
// Mirrors DropdownMenuContent: themed elevated surface, viewport-aware
// (Radix flips/shifts off edges), with the standard open/close motion.
className={cn(
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-2 text-popover-foreground shadow-md backdrop-blur-md outline-hidden data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
collisionPadding={collisionPadding}
data-slot="popover-content"
sideOffset={sideOffset}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }

View File

@ -30,6 +30,28 @@ export function profileColor(name: null | string | undefined): null | string {
return `hsl(${hue} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)` return `hsl(${hue} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)`
} }
// A profile's effective color: a user-picked override wins, else the
// deterministic hue. Default/empty stays neutral (null) regardless.
export function resolveProfileColor(
name: null | string | undefined,
overrides: Record<string, string>
): null | string {
const key = (name ?? '').trim()
if (!key || key === 'default') {
return null
}
return overrides[key] ?? profileColor(key)
}
// Curated swatches for the rail color picker — evenly spaced hues at the same
// saturation/lightness as the deterministic palette, so picks stay cohesive.
export const PROFILE_SWATCHES: readonly string[] = Array.from(
{ length: 12 },
(_, index) => `hsl(${index * 30} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)`
)
// Translucent fill derived from a profile color, for tag backgrounds. // Translucent fill derived from a profile color, for tag backgrounds.
export function profileColorSoft(color: string, percent = 16): string { export function profileColorSoft(color: string, percent = 16): string {
return `color-mix(in srgb, ${color} ${percent}%, transparent)` return `color-mix(in srgb, ${color} ${percent}%, transparent)`

View File

@ -64,6 +64,36 @@ export function persistStringArray(key: string, value: string[]) {
} }
} }
export function storedStringRecord(key: string): Record<string, string> {
try {
const value = window.localStorage.getItem(key)
if (!value) {
return {}
}
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)
} catch {
return {}
}
}
export function persistStringRecord(key: string, value: Record<string, string>) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
// Local preference; restricted storage should not break the app.
}
}
export function arraysEqual(left: string[], right: string[]) { export function arraysEqual(left: string[], right: string[]) {
return left.length === right.length && left.every((item, index) => item === right[index]) return left.length === right.length && left.every((item, index) => item === right[index])
} }

View File

@ -3,7 +3,15 @@ import { atom, computed } from 'nanostores'
import { getProfiles, setApiRequestProfile } from '@/hermes' import { getProfiles, setApiRequestProfile } from '@/hermes'
import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { queryClient } from '@/lib/query-client' import { queryClient } from '@/lib/query-client'
import { arraysEqual, persistBoolean, persistStringArray, storedBoolean, storedStringArray } from '@/lib/storage' import {
arraysEqual,
persistBoolean,
persistStringArray,
persistStringRecord,
storedBoolean,
storedStringArray,
storedStringRecord
} from '@/lib/storage'
import { $gateway } from '@/store/gateway' import { $gateway } from '@/store/gateway'
import { setConnection } from '@/store/session' import { setConnection } from '@/store/session'
import type { ProfileInfo } from '@/types/hermes' import type { ProfileInfo } from '@/types/hermes'
@ -62,6 +70,30 @@ export function sortByProfileOrder<T extends { name: string }>(items: T[], order
}) })
} }
// ── Rail colors ────────────────────────────────────────────────────────────
// Optional per-profile color override (long-press a rail square to pick). Absent
// names fall back to the deterministic hue from profileColor(); a local-only
// cosmetic preference, so single-profile users never touch it.
const PROFILE_COLORS_STORAGE_KEY = 'hermes.desktop.profileColors'
export const $profileColors = atom<Record<string, string>>(storedStringRecord(PROFILE_COLORS_STORAGE_KEY))
$profileColors.subscribe(value => persistStringRecord(PROFILE_COLORS_STORAGE_KEY, value))
// Set (or, with null, clear) a profile's color override.
export function setProfileColor(name: string, color: null | string): void {
const key = normalizeProfileKey(name)
const next = { ...$profileColors.get() }
if (color) {
next[key] = color
} else {
delete next[key]
}
$profileColors.set(next)
}
interface ActiveProfileResponse { interface ActiveProfileResponse {
active: string active: string
current: string current: string