feat(popover): add expandOnHover, added the ability to change the color of a workflow icon, new workflow naming convention (#2770)

* feat(popover): add expandOnHover, added the ability to change the color of a workflow icon

* updated workflow naming conventions
This commit is contained in:
Waleed
2026-01-10 21:30:34 -08:00
committed by GitHub
parent e347486f50
commit 7f18d96d32
15 changed files with 1040 additions and 436 deletions
@@ -163,7 +163,7 @@ function AddMembersModal({
className='flex items-center gap-[10px] rounded-[4px] px-[8px] py-[6px] hover:bg-[var(--surface-2)]'
>
<Checkbox checked={isSelected} />
<Avatar size='xs'>
<Avatar size='sm'>
{member.user?.image && (
<AvatarImage src={member.user.image} alt={name} />
)}
@@ -663,7 +663,7 @@ export function AccessControl() {
return (
<div key={member.id} className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-[12px]'>
<Avatar size='sm'>
<Avatar size='md'>
{member.userImage && <AvatarImage src={member.userImage} alt={name} />}
<AvatarFallback
style={{
@@ -434,12 +434,10 @@ export function CredentialSets() {
filteredOwnedSets.length === 0 &&
!hasNoContent
// Early returns AFTER all hooks
if (membershipsLoading || invitationsLoading) {
return <CredentialSetsSkeleton />
}
// Detail view for a polling group
if (viewingSet) {
const activeMembers = members.filter((m) => m.status === 'active')
const totalCount = activeMembers.length + pendingInvitations.length
@@ -529,7 +527,7 @@ export function CredentialSets() {
return (
<div key={member.id} className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-[12px]'>
<Avatar size='sm'>
<Avatar size='md'>
{member.userImage && (
<AvatarImage src={member.userImage} alt={name} />
)}
@@ -583,7 +581,7 @@ export function CredentialSets() {
return (
<div key={invitation.id} className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-[12px]'>
<Avatar size='sm'>
<Avatar size='md'>
<AvatarFallback
style={{ background: getUserColor(email) }}
className='border-0 text-white'
@@ -1,12 +1,41 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check } from 'lucide-react'
import {
Popover,
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverDivider,
PopoverFolder,
PopoverItem,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { WORKFLOW_COLORS } from '@/lib/workflows/colors'
/**
* Validates a hex color string.
* Accepts 3 or 6 character hex codes with or without #.
*/
function isValidHex(hex: string): boolean {
const cleaned = hex.replace('#', '')
return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(cleaned)
}
/**
* Normalizes a hex color to lowercase 6-character format with #.
*/
function normalizeHex(hex: string): string {
let cleaned = hex.replace('#', '').toLowerCase()
if (cleaned.length === 3) {
cleaned = cleaned
.split('')
.map((c) => c + c)
.join('')
}
return `#${cleaned}`
}
interface ContextMenuProps {
/**
@@ -53,6 +82,14 @@ interface ContextMenuProps {
* Callback when delete is clicked
*/
onDelete: () => void
/**
* Callback when color is changed
*/
onColorChange?: (color: string) => void
/**
* Current workflow color (for showing selected state)
*/
currentColor?: string
/**
* Whether to show the open in new tab option (default: false)
* Set to true for items that can be opened in a new tab
@@ -83,11 +120,21 @@ interface ContextMenuProps {
* Set to true for items that can be exported (like workspaces)
*/
showExport?: boolean
/**
* Whether to show the change color option (default: false)
* Set to true for workflows to allow color customization
*/
showColorChange?: boolean
/**
* Whether the export option is disabled (default: false)
* Set to true when user lacks permissions
*/
disableExport?: boolean
/**
* Whether the change color option is disabled (default: false)
* Set to true when user lacks permissions
*/
disableColorChange?: boolean
/**
* Whether the rename option is disabled (default: false)
* Set to true when user lacks permissions
@@ -134,23 +181,74 @@ export function ContextMenu({
onDuplicate,
onExport,
onDelete,
onColorChange,
currentColor,
showOpenInNewTab = false,
showRename = true,
showCreate = false,
showCreateFolder = false,
showDuplicate = true,
showExport = false,
showColorChange = false,
disableExport = false,
disableColorChange = false,
disableRename = false,
disableDuplicate = false,
disableDelete = false,
disableCreate = false,
disableCreateFolder = false,
}: ContextMenuProps) {
// Section visibility for divider logic
const [hexInput, setHexInput] = useState(currentColor || '#ffffff')
// Sync hexInput when currentColor changes (e.g., opening menu on different workflow)
useEffect(() => {
setHexInput(currentColor || '#ffffff')
}, [currentColor])
const canSubmitHex = useMemo(() => {
if (!isValidHex(hexInput)) return false
const normalized = normalizeHex(hexInput)
if (currentColor && normalized.toLowerCase() === currentColor.toLowerCase()) return false
return true
}, [hexInput, currentColor])
const handleHexSubmit = useCallback(() => {
if (!canSubmitHex || !onColorChange) return
const normalized = normalizeHex(hexInput)
onColorChange(normalized)
setHexInput(normalized)
}, [hexInput, canSubmitHex, onColorChange])
const handleHexKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleHexSubmit()
}
},
[handleHexSubmit]
)
const handleHexChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value.trim()
if (value && !value.startsWith('#')) {
value = `#${value}`
}
value = value.slice(0, 1) + value.slice(1).replace(/[^0-9a-fA-F]/g, '')
setHexInput(value.slice(0, 7))
}, [])
const handleHexFocus = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
e.target.select()
}, [])
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasEditSection =
(showRename && onRename) || (showCreate && onCreate) || (showCreateFolder && onCreateFolder)
(showRename && onRename) ||
(showCreate && onCreate) ||
(showCreateFolder && onCreateFolder) ||
(showColorChange && onColorChange)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
return (
@@ -170,10 +268,21 @@ export function ContextMenu({
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverContent
ref={menuRef}
align='start'
side='bottom'
sideOffset={4}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
{/* Back button - shown only when in a folder */}
<PopoverBackButton />
{/* Navigation actions */}
{showOpenInNewTab && onOpenInNewTab && (
<PopoverItem
rootOnly
onClick={() => {
onOpenInNewTab()
onClose()
@@ -182,11 +291,12 @@ export function ContextMenu({
Open in new tab
</PopoverItem>
)}
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider />}
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider rootOnly />}
{/* Edit and create actions */}
{showRename && onRename && (
<PopoverItem
rootOnly
disabled={disableRename}
onClick={() => {
onRename()
@@ -198,6 +308,7 @@ export function ContextMenu({
)}
{showCreate && onCreate && (
<PopoverItem
rootOnly
disabled={disableCreate}
onClick={() => {
onCreate()
@@ -209,6 +320,7 @@ export function ContextMenu({
)}
{showCreateFolder && onCreateFolder && (
<PopoverItem
rootOnly
disabled={disableCreateFolder}
onClick={() => {
onCreateFolder()
@@ -218,11 +330,72 @@ export function ContextMenu({
Create folder
</PopoverItem>
)}
{showColorChange && onColorChange && (
<PopoverFolder
id='color-picker'
title='Change color'
expandOnHover
className={disableColorChange ? 'pointer-events-none opacity-50' : ''}
>
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
{/* Preset colors */}
<div className='grid grid-cols-6 gap-[4px]'>
{WORKFLOW_COLORS.map(({ color, name }) => (
<button
key={color}
type='button'
title={name}
onClick={(e) => {
e.stopPropagation()
onColorChange(color)
}}
className={cn(
'h-[20px] w-[20px] rounded-[4px]',
currentColor?.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
)}
style={{ backgroundColor: color }}
/>
))}
</div>
{/* Hex input */}
<div className='flex items-center gap-[4px]'>
<div
className='h-[20px] w-[20px] flex-shrink-0 rounded-[4px]'
style={{
backgroundColor: isValidHex(hexInput) ? normalizeHex(hexInput) : '#ffffff',
}}
/>
<input
type='text'
value={hexInput}
onChange={handleHexChange}
onKeyDown={handleHexKeyDown}
onFocus={handleHexFocus}
onClick={(e) => e.stopPropagation()}
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase focus:outline-none'
/>
<button
type='button'
disabled={!canSubmitHex}
onClick={(e) => {
e.stopPropagation()
handleHexSubmit()
}}
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--brand-tertiary-2)] text-white disabled:opacity-40'
>
<Check className='h-[12px] w-[12px]' />
</button>
</div>
</div>
</PopoverFolder>
)}
{/* Copy and export actions */}
{hasEditSection && hasCopySection && <PopoverDivider />}
{hasEditSection && hasCopySection && <PopoverDivider rootOnly />}
{showDuplicate && onDuplicate && (
<PopoverItem
rootOnly
disabled={disableDuplicate}
onClick={() => {
onDuplicate()
@@ -234,6 +407,7 @@ export function ContextMenu({
)}
{showExport && onExport && (
<PopoverItem
rootOnly
disabled={disableExport}
onClick={() => {
onExport()
@@ -245,8 +419,9 @@ export function ContextMenu({
)}
{/* Destructive action */}
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider />}
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider rootOnly />}
<PopoverItem
rootOnly
disabled={disableDelete}
onClick={() => {
onDelete()
@@ -3,8 +3,9 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
@@ -23,10 +24,7 @@ import {
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import type { FolderTreeNode } from '@/stores/folders/types'
import {
generateCreativeWorkflowName,
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('FolderItem')
@@ -173,6 +171,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
menuRef,
handleContextMenu,
closeMenu,
preventDismiss,
} = useContextMenu()
// Rename hook
@@ -242,6 +241,40 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
[isEditing, handleRenameKeyDown, handleExpandKeyDown]
)
/**
* Handle more button pointerdown - prevents click-outside dismissal when toggling
*/
const handleMorePointerDown = useCallback(() => {
if (isContextMenuOpen) {
preventDismiss()
}
}, [isContextMenuOpen, preventDismiss])
/**
* Handle more button click - toggles context menu at button position
*/
const handleMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
// Toggle: close if open, open if closed
if (isContextMenuOpen) {
closeMenu()
return
}
const rect = e.currentTarget.getBoundingClientRect()
handleContextMenu({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.top,
} as React.MouseEvent)
},
[isContextMenuOpen, closeMenu, handleContextMenu]
)
return (
<>
<div
@@ -303,12 +336,22 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
spellCheck='false'
/>
) : (
<span
className='truncate font-medium text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
onDoubleClick={handleDoubleClick}
>
{folder.name}
</span>
<>
<span
className='min-w-0 flex-1 truncate font-medium text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
onDoubleClick={handleDoubleClick}
>
{folder.name}
</span>
<button
type='button'
onPointerDown={handleMorePointerDown}
onClick={handleMoreClick}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] opacity-0 transition-opacity hover:bg-[var(--surface-7)] group-hover:opacity-100'
>
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
</button>
</>
)}
</div>
@@ -1,15 +1,24 @@
'use client'
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { Tooltip } from '@/components/emcn'
import { type CSSProperties, useEffect, useMemo } from 'react'
import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getUserColor } from '@/lib/workspaces/colors'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useSidebarStore } from '@/stores/sidebar/store'
/**
* Avatar display configuration for responsive layout.
*/
const AVATAR_CONFIG = {
MIN_COUNT: 3,
MAX_COUNT: 12,
WIDTH_PER_AVATAR: 20,
} as const
interface AvatarsProps {
workflowId: string
maxVisible?: number
/**
* Callback fired when the presence visibility changes.
* Used by parent components to adjust layout (e.g., text truncation spacing).
@@ -30,45 +39,29 @@ interface UserAvatarProps {
}
/**
* Individual user avatar with error handling for image loading.
* Individual user avatar using emcn Avatar component.
* Falls back to colored circle with initials if image fails to load.
*/
function UserAvatar({ user, index }: UserAvatarProps) {
const [imageError, setImageError] = useState(false)
const color = getUserColor(user.userId)
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
const hasAvatar = Boolean(user.avatarUrl) && !imageError
// Reset error state when avatar URL changes
useEffect(() => {
setImageError(false)
}, [user.avatarUrl])
const avatarElement = (
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full font-semibold text-[7px] text-white'
style={
{
background: hasAvatar ? undefined : color,
zIndex: 10 - index,
} as CSSProperties
}
>
{hasAvatar && user.avatarUrl ? (
<Image
<Avatar size='xs' style={{ zIndex: index + 1 } as CSSProperties}>
{user.avatarUrl && (
<AvatarImage
src={user.avatarUrl}
alt={user.userName ? `${user.userName}'s avatar` : 'User avatar'}
fill
sizes='14px'
className='object-cover'
referrerPolicy='no-referrer'
unoptimized
onError={() => setImageError(true)}
/>
) : (
initials
)}
</div>
<AvatarFallback
style={{ background: color }}
className='border-0 font-semibold text-[7px] text-white'
>
{initials}
</AvatarFallback>
</Avatar>
)
if (user.userName) {
@@ -92,14 +85,26 @@ function UserAvatar({ user, index }: UserAvatarProps) {
* @param props - Component props
* @returns Avatar stack for workflow presence
*/
export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: AvatarsProps) {
export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
const { presenceUsers, currentWorkflowId } = useSocket()
const { data: session } = useSession()
const currentUserId = session?.user?.id
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
/**
* Only show presence for the currently active workflow
* Filter out the current user from the list
* Calculate max visible avatars based on sidebar width.
* Scales between MIN_COUNT and MAX_COUNT as sidebar expands.
*/
const maxVisible = useMemo(() => {
const widthDelta = sidebarWidth - SIDEBAR_WIDTH.MIN
const additionalAvatars = Math.floor(widthDelta / AVATAR_CONFIG.WIDTH_PER_AVATAR)
const calculated = AVATAR_CONFIG.MIN_COUNT + additionalAvatars
return Math.max(AVATAR_CONFIG.MIN_COUNT, Math.min(AVATAR_CONFIG.MAX_COUNT, calculated))
}, [sidebarWidth])
/**
* Only show presence for the currently active workflow.
* Filter out the current user from the list.
*/
const workflowUsers = useMemo(() => {
if (currentWorkflowId !== workflowId) {
@@ -122,7 +127,6 @@ export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: Avatar
return { visibleUsers: visible, overflowCount: overflow }
}, [workflowUsers, maxVisible])
// Notify parent when avatars are present or not
useEffect(() => {
const hasAnyAvatars = visibleUsers.length > 0
if (typeof onPresenceChange === 'function') {
@@ -135,26 +139,25 @@ export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: Avatar
}
return (
<div className='-space-x-1 ml-[-8px] flex items-center'>
{visibleUsers.map((user, index) => (
<UserAvatar key={user.socketId} user={user} index={index} />
))}
<div className='-space-x-1 flex items-center'>
{overflowCount > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full bg-[#404040] font-semibold text-[7px] text-white'
style={{ zIndex: 10 - visibleUsers.length } as CSSProperties}
>
+{overflowCount}
</div>
<Avatar size='xs' style={{ zIndex: 0 } as CSSProperties}>
<AvatarFallback className='border-0 bg-[#404040] font-semibold text-[7px] text-white'>
+{overflowCount}
</AvatarFallback>
</Avatar>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
</Tooltip.Content>
</Tooltip.Root>
)}
{visibleUsers.map((user, index) => (
<UserAvatar key={user.socketId} user={user} index={overflowCount > 0 ? index + 1 : index} />
))}
</div>
)
}
@@ -2,6 +2,7 @@
import { useCallback, useRef, useState } from 'react'
import clsx from 'clsx'
import { MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -108,6 +109,16 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
window.open(`/workspace/${workspaceId}/w/${workflow.id}`, '_blank')
}, [workspaceId, workflow.id])
/**
* Changes the workflow color
*/
const handleColorChange = useCallback(
(color: string) => {
updateWorkflow(workflow.id, { color })
},
[workflow.id, updateWorkflow]
)
/**
* Drag start handler - handles workflow dragging with multi-selection support
*
@@ -142,8 +153,38 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
menuRef,
handleContextMenu: handleContextMenuBase,
closeMenu,
preventDismiss,
} = useContextMenu()
/**
* Captures selection state for context menu operations
*/
const captureSelectionState = useCallback(() => {
const { selectedWorkflows: currentSelection, selectOnly } = useFolderStore.getState()
const isCurrentlySelected = currentSelection.has(workflow.id)
if (!isCurrentlySelected) {
selectOnly(workflow.id)
}
const finalSelection = useFolderStore.getState().selectedWorkflows
const finalIsSelected = finalSelection.has(workflow.id)
const workflowIds =
finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
const workflowNames = workflowIds
.map((id) => workflows[id]?.name)
.filter((name): name is string => !!name)
capturedSelectionRef.current = {
workflowIds,
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
}
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
}, [workflow.id, workflows, canDeleteWorkflows])
/**
* Handle right-click - ensure proper selection behavior and capture selection state
* If right-clicking on an unselected workflow, select only that workflow
@@ -151,39 +192,46 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
*/
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
// Check current selection state at time of right-click
const { selectedWorkflows: currentSelection, selectOnly } = useFolderStore.getState()
const isCurrentlySelected = currentSelection.has(workflow.id)
// If this workflow is not in the current selection, select only this workflow
if (!isCurrentlySelected) {
selectOnly(workflow.id)
}
// Capture the selection state at right-click time
const finalSelection = useFolderStore.getState().selectedWorkflows
const finalIsSelected = finalSelection.has(workflow.id)
const workflowIds =
finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
const workflowNames = workflowIds
.map((id) => workflows[id]?.name)
.filter((name): name is string => !!name)
// Store in ref so it persists even if selection changes
capturedSelectionRef.current = {
workflowIds,
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
}
// Check if the captured selection can be deleted
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
// If already selected with multiple selections, keep all selections
captureSelectionState()
handleContextMenuBase(e)
},
[workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
[captureSelectionState, handleContextMenuBase]
)
/**
* Handle more button pointerdown - prevents click-outside dismissal when toggling
*/
const handleMorePointerDown = useCallback(() => {
if (isContextMenuOpen) {
preventDismiss()
}
}, [isContextMenuOpen, preventDismiss])
/**
* Handle more button click - toggles context menu at button position
*/
const handleMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
// Toggle: close if open, open if closed
if (isContextMenuOpen) {
closeMenu()
return
}
captureSelectionState()
// Open context menu aligned with the button
const rect = e.currentTarget.getBoundingClientRect()
handleContextMenuBase({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.top,
} as React.MouseEvent)
},
[isContextMenuOpen, closeMenu, captureSelectionState, handleContextMenuBase]
)
// Rename hook
@@ -309,7 +357,17 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
)}
</div>
{!isEditing && (
<Avatars workflowId={workflow.id} maxVisible={3} onPresenceChange={setHasAvatars} />
<>
<Avatars workflowId={workflow.id} onPresenceChange={setHasAvatars} />
<button
type='button'
onPointerDown={handleMorePointerDown}
onClick={handleMoreClick}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] opacity-0 transition-opacity hover:bg-[var(--surface-7)] group-hover:opacity-100'
>
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
</button>
</>
)}
</Link>
@@ -324,13 +382,17 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
onDuplicate={handleDuplicateWorkflow}
onExport={handleExportWorkflow}
onDelete={handleOpenDeleteModal}
onColorChange={handleColorChange}
currentColor={workflow.color}
showOpenInNewTab={selectedWorkflows.size <= 1}
showRename={selectedWorkflows.size <= 1}
showDuplicate={true}
showExport={true}
showColorChange={selectedWorkflows.size <= 1}
disableRename={!userPermissions.canEdit}
disableDuplicate={!userPermissions.canEdit}
disableExport={!userPermissions.canEdit}
disableColorChange={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit || !canDeleteCaptured}
/>
@@ -27,6 +27,8 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
const [isOpen, setIsOpen] = useState(false)
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
const menuRef = useRef<HTMLDivElement>(null)
// Used to prevent click-outside dismissal when trigger is clicked
const dismissPreventedRef = useRef(false)
/**
* Handle right-click event
@@ -55,6 +57,14 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
setIsOpen(false)
}, [])
/**
* Prevent the next click-outside from dismissing the menu.
* Call this on pointerdown of a toggle trigger to allow proper toggle behavior.
*/
const preventDismiss = useCallback(() => {
dismissPreventedRef.current = true
}, [])
/**
* Handle clicks outside the menu to close it
*/
@@ -62,6 +72,11 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
// Check if dismissal was prevented (e.g., by toggle trigger's pointerdown)
if (dismissPreventedRef.current) {
dismissPreventedRef.current = false
return
}
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeMenu()
}
@@ -84,5 +99,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
menuRef,
handleContextMenu,
closeMenu,
preventDismiss,
}
}
@@ -1,13 +1,11 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import {
generateCreativeWorkflowName,
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('useWorkflowOperations')
@@ -1,10 +1,10 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
const logger = createLogger('useDuplicateWorkflow')
@@ -12,10 +12,10 @@ import { cn } from '@/lib/core/utils/cn'
const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full', {
variants: {
size: {
xs: 'h-6 w-6',
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xs: 'h-3.5 w-3.5',
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-10 w-10',
},
},
defaultVariants: {
@@ -37,10 +37,10 @@ const avatarStatusVariants = cva(
away: 'bg-[#f59e0b]',
},
size: {
xs: 'h-2 w-2',
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-3.5 w-3.5',
xs: 'h-1.5 w-1.5 border',
sm: 'h-2 w-2',
md: 'h-2.5 w-2.5',
lg: 'h-3 w-3',
},
},
defaultVariants: {
@@ -52,6 +52,7 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { Check, ChevronLeft, ChevronRight, Search } from 'lucide-react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/core/utils/cn'
type PopoverSize = 'sm' | 'md'
@@ -166,6 +167,9 @@ interface PopoverContextValue {
colorScheme: PopoverColorScheme
searchQuery: string
setSearchQuery: (query: string) => void
/** ID of the last hovered item (for hover submenus) */
lastHoveredItem: string | null
setLastHoveredItem: (id: string | null) => void
}
const PopoverContext = React.createContext<PopoverContextValue | null>(null)
@@ -208,12 +212,24 @@ const Popover: React.FC<PopoverProps> = ({
variant = 'default',
size = 'md',
colorScheme = 'default',
open,
...props
}) => {
const [currentFolder, setCurrentFolder] = React.useState<string | null>(null)
const [folderTitle, setFolderTitle] = React.useState<string | null>(null)
const [onFolderSelect, setOnFolderSelect] = React.useState<(() => void) | null>(null)
const [searchQuery, setSearchQuery] = React.useState<string>('')
const [lastHoveredItem, setLastHoveredItem] = React.useState<string | null>(null)
React.useEffect(() => {
if (open === false) {
setCurrentFolder(null)
setFolderTitle(null)
setOnFolderSelect(null)
setSearchQuery('')
setLastHoveredItem(null)
}
}, [open])
const openFolder = React.useCallback(
(id: string, title: string, onLoad?: () => void | Promise<void>, onSelect?: () => void) => {
@@ -246,6 +262,8 @@ const Popover: React.FC<PopoverProps> = ({
colorScheme,
searchQuery,
setSearchQuery,
lastHoveredItem,
setLastHoveredItem,
}),
[
openFolder,
@@ -257,12 +275,15 @@ const Popover: React.FC<PopoverProps> = ({
size,
colorScheme,
searchQuery,
lastHoveredItem,
]
)
return (
<PopoverContext.Provider value={contextValue}>
<PopoverPrimitive.Root {...props}>{children}</PopoverPrimitive.Root>
<PopoverPrimitive.Root open={open} {...props}>
{children}
</PopoverPrimitive.Root>
</PopoverContext.Provider>
)
}
@@ -496,7 +517,17 @@ export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
*/
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
(
{ className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
{
className,
active,
rootOnly,
disabled,
showCheck = false,
children,
onClick,
onMouseEnter,
...props
},
ref
) => {
const context = React.useContext(PopoverContext)
@@ -514,6 +545,12 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
onClick?.(e)
}
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
// Clear last hovered item to close any open hover submenus
context?.setLastHoveredItem(null)
onMouseEnter?.(e)
}
return (
<div
className={cn(
@@ -529,6 +566,7 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
aria-selected={active}
aria-disabled={disabled}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
{...props}
>
{children}
@@ -589,44 +627,150 @@ export interface PopoverFolderProps extends Omit<React.HTMLAttributes<HTMLDivEle
children?: React.ReactNode
/** Whether currently active/selected */
active?: boolean
/**
* Expand folder on hover to show submenu alongside parent
* When true, hovering shows a floating submenu; clicking still uses inline navigation
* @default false
*/
expandOnHover?: boolean
}
/**
* Expandable folder that shows nested content.
* Supports two modes:
* - Click mode (default): Replaces parent content, shows back button
* - Hover mode (expandOnHover): Shows floating submenu alongside parent
*/
const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
({ className, id, title, icon, onOpen, onSelect, children, active, ...props }, ref) => {
const { openFolder, currentFolder, isInFolder, variant, size, colorScheme } =
usePopoverContext()
(
{
className,
id,
title,
icon,
onOpen,
onSelect,
children,
active,
expandOnHover = false,
...props
},
ref
) => {
const {
openFolder,
currentFolder,
isInFolder,
variant,
size,
colorScheme,
lastHoveredItem,
setLastHoveredItem,
} = usePopoverContext()
const [submenuPosition, setSubmenuPosition] = React.useState<{ top: number; left: number }>({
top: 0,
left: 0,
})
const triggerRef = React.useRef<HTMLDivElement>(null)
// Submenu is open when this folder is the last hovered item (for expandOnHover mode)
const isHoverOpen = expandOnHover && lastHoveredItem === id
// Merge refs
const mergedRef = React.useCallback(
(node: HTMLDivElement | null) => {
triggerRef.current = node
if (typeof ref === 'function') {
ref(node)
} else if (ref) {
ref.current = node
}
},
[ref]
)
// If we're in a folder and this isn't the current one, hide
if (isInFolder && currentFolder !== id) return null
// If this folder is open via click (inline mode), render children directly
if (currentFolder === id) return <>{children}</>
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
const handleClickOpen = () => {
openFolder(id, title, onOpen, onSelect)
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (expandOnHover) {
// In hover mode, clicking opens inline and clears hover state
setLastHoveredItem(null)
}
handleClickOpen()
}
const handleMouseEnter = () => {
if (!expandOnHover) return
// Calculate position for submenu
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
const parentPopover = triggerRef.current.closest('[data-radix-popper-content-wrapper]')
const parentRect = parentPopover?.getBoundingClientRect()
// Position to the right of the parent popover with a small gap
setSubmenuPosition({
top: rect.top,
left: parentRect ? parentRect.right + 4 : rect.right + 4,
})
}
setLastHoveredItem(id)
onOpen?.()
}
return (
<div
ref={ref}
className={cn(
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!active),
className
)}
role='menuitem'
aria-haspopup='true'
aria-expanded={false}
onClick={handleClick}
{...props}
>
{icon}
<span className='flex-1'>{title}</span>
<ChevronRight className={STYLES.size[size].icon} />
</div>
<>
<div
ref={mergedRef}
className={cn(
STYLES.itemBase,
STYLES.colorScheme[colorScheme].text,
STYLES.size[size].item,
getItemStateClasses(variant, colorScheme, !!active || isHoverOpen),
className
)}
role='menuitem'
aria-haspopup='true'
aria-expanded={isHoverOpen}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
{...props}
>
{icon}
<span className='flex-1'>{title}</span>
<ChevronRight className={STYLES.size[size].icon} />
</div>
{/* Hover submenu - rendered as a portal to escape overflow clipping */}
{isHoverOpen &&
typeof document !== 'undefined' &&
createPortal(
<div
className={cn(
'fixed z-[10000201] min-w-[120px]',
STYLES.content,
STYLES.colorScheme[colorScheme].content,
'shadow-lg'
)}
style={{
top: submenuPosition.top,
left: submenuPosition.left,
}}
>
{children}
</div>,
document.body
)}
</>
)
}
)
@@ -665,7 +809,10 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
className
)}
role='button'
onClick={closeFolder}
onClick={(e) => {
e.stopPropagation()
closeFolder()
}}
{...props}
>
<ChevronLeft className={STYLES.size[size].icon} />
+2 -4
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import {
createOptimisticMutationHandlers,
@@ -8,10 +9,7 @@ import {
} from '@/hooks/queries/utils/optimistic-mutation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import {
generateCreativeWorkflowName,
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
+75
View File
@@ -0,0 +1,75 @@
/**
* Workflow color constants and utilities.
* Centralized location for all workflow color-related functionality.
*
* Colors are aligned with the brand color scheme:
* - Purple: brand-400 (#8e4cfb)
* - Blue: brand-secondary (#33b4ff)
* - Green: brand-tertiary (#22c55e)
* - Red: text-error (#ef4444)
* - Orange: warning (#f97316)
* - Pink: (#ec4899)
*/
/**
* Full list of available workflow colors with names.
* Used for color picker and random color assignment.
* Each base color has 6 vibrant shades optimized for both light and dark themes.
*/
export const WORKFLOW_COLORS = [
// Shade 1 - all base colors (brightest)
{ color: '#c084fc', name: 'Purple 1' },
{ color: '#5ed8ff', name: 'Blue 1' },
{ color: '#4aea7f', name: 'Green 1' },
{ color: '#ff6b6b', name: 'Red 1' },
{ color: '#ff9642', name: 'Orange 1' },
{ color: '#f472b6', name: 'Pink 1' },
// Shade 2 - all base colors
{ color: '#a855f7', name: 'Purple 2' },
{ color: '#38c8ff', name: 'Blue 2' },
{ color: '#2ed96a', name: 'Green 2' },
{ color: '#ff5555', name: 'Red 2' },
{ color: '#ff8328', name: 'Orange 2' },
{ color: '#ec4899', name: 'Pink 2' },
// Shade 3 - all base colors
{ color: '#9333ea', name: 'Purple 3' },
{ color: '#33b4ff', name: 'Blue 3' },
{ color: '#22c55e', name: 'Green 3' },
{ color: '#ef4444', name: 'Red 3' },
{ color: '#f97316', name: 'Orange 3' },
{ color: '#e11d89', name: 'Pink 3' },
// Shade 4 - all base colors
{ color: '#8e4cfb', name: 'Purple 4' },
{ color: '#1e9de8', name: 'Blue 4' },
{ color: '#18b04c', name: 'Green 4' },
{ color: '#dc3535', name: 'Red 4' },
{ color: '#e56004', name: 'Orange 4' },
{ color: '#d61c7a', name: 'Pink 4' },
// Shade 5 - all base colors
{ color: '#7c3aed', name: 'Purple 5' },
{ color: '#1486d1', name: 'Blue 5' },
{ color: '#0e9b3a', name: 'Green 5' },
{ color: '#c92626', name: 'Red 5' },
{ color: '#d14d00', name: 'Orange 5' },
{ color: '#be185d', name: 'Pink 5' },
// Shade 6 - all base colors (darkest)
{ color: '#6322c9', name: 'Purple 6' },
{ color: '#0a6fb8', name: 'Blue 6' },
{ color: '#048628', name: 'Green 6' },
{ color: '#b61717', name: 'Red 6' },
{ color: '#bd3a00', name: 'Orange 6' },
{ color: '#9d174d', name: 'Pink 6' },
] as const
/**
* Generates a random color for a new workflow
* @returns A hex color string from the available workflow colors
*/
export function getNextWorkflowColor(): string {
return WORKFLOW_COLORS[Math.floor(Math.random() * WORKFLOW_COLORS.length)].color
}
+1 -1
View File
@@ -3,6 +3,7 @@ import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type {
@@ -11,7 +12,6 @@ import type {
WorkflowMetadata,
WorkflowRegistry,
} from '@/stores/workflows/registry/types'
import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
+379 -290
View File
@@ -1,321 +1,410 @@
// Available workflow colors
export const WORKFLOW_COLORS = [
// Blues - vibrant blue tones
'#3972F6', // Blue (original)
'#2E5BF5', // Deeper Blue
'#1E4BF4', // Royal Blue
'#0D3BF3', // Deep Royal Blue
// Pinks/Magentas - vibrant pink and magenta tones
'#F639DD', // Pink/Magenta (original)
'#F529CF', // Deep Magenta
'#F749E7', // Light Magenta
'#F419C1', // Hot Pink
// Oranges/Yellows - vibrant orange and yellow tones
'#F6B539', // Orange/Yellow (original)
'#F5A529', // Deep Orange
'#F49519', // Burnt Orange
'#F38509', // Deep Burnt Orange
// Purples - vibrant purple tones
'#8139F6', // Purple (original)
'#7129F5', // Deep Purple
'#6119F4', // Royal Purple
'#5109F3', // Deep Royal Purple
// Greens - vibrant green tones
'#39B54A', // Green (original)
'#29A53A', // Deep Green
'#19952A', // Forest Green
'#09851A', // Deep Forest Green
// Teals/Cyans - vibrant teal and cyan tones
'#39B5AB', // Teal (original)
'#29A59B', // Deep Teal
'#19958B', // Dark Teal
'#09857B', // Deep Dark Teal
// Reds/Red-Oranges - vibrant red and red-orange tones
'#F66839', // Red/Orange (original)
'#F55829', // Deep Red-Orange
'#F44819', // Burnt Red
'#F33809', // Deep Burnt Red
// Additional vibrant colors for variety
// Corals - warm coral tones
'#F6397A', // Coral
'#F5296A', // Deep Coral
'#F7498A', // Light Coral
// Crimsons - deep red tones
'#DC143C', // Crimson
'#CC042C', // Deep Crimson
'#EC243C', // Light Crimson
'#BC003C', // Dark Crimson
'#FC343C', // Bright Crimson
// Mint - fresh green tones
'#00FF7F', // Mint Green
'#00EF6F', // Deep Mint
'#00DF5F', // Dark Mint
// Slate - blue-gray tones
'#6A5ACD', // Slate Blue
'#5A4ABD', // Deep Slate
'#4A3AAD', // Dark Slate
// Amber - warm orange-yellow tones
'#FFBF00', // Amber
'#EFAF00', // Deep Amber
'#DF9F00', // Dark Amber
]
// Generates a random color for a new workflow
export function getNextWorkflowColor(): string {
// Simply return a random color from the available colors
return WORKFLOW_COLORS[Math.floor(Math.random() * WORKFLOW_COLORS.length)]
}
// Adjectives and nouns for creative workflow names
// Cosmos-themed adjectives and nouns for creative workflow names (max 9 chars each)
const ADJECTIVES = [
'Blazing',
'Crystal',
'Golden',
'Silver',
'Mystic',
'Cosmic',
'Electric',
'Frozen',
'Burning',
'Shining',
'Dancing',
'Flying',
'Roaring',
'Whispering',
'Glowing',
'Sparkling',
'Thunder',
'Lightning',
'Storm',
'Ocean',
'Mountain',
'Forest',
'Desert',
'Arctic',
'Tropical',
'Midnight',
'Dawn',
'Sunset',
'Rainbow',
'Diamond',
'Ruby',
'Emerald',
'Sapphire',
'Pearl',
'Jade',
'Amber',
'Coral',
'Ivory',
'Obsidian',
'Marble',
'Velvet',
'Silk',
'Satin',
'Linen',
'Cotton',
'Wool',
'Cashmere',
'Denim',
'Neon',
'Pastel',
'Vibrant',
'Muted',
'Bold',
'Subtle',
'Bright',
'Dark',
'Ancient',
'Modern',
'Eternal',
'Swift',
// Light & Luminosity
'Radiant',
'Quantum',
'Luminous',
'Blazing',
'Glowing',
'Bright',
'Gleaming',
'Shining',
'Lustrous',
'Flaring',
'Vivid',
'Dazzling',
'Beaming',
'Brilliant',
'Lit',
'Ablaze',
// Celestial Descriptors
'Stellar',
'Cosmic',
'Astral',
'Galactic',
'Nebular',
'Orbital',
'Lunar',
'Solar',
'Starlit',
'Heavenly',
'Celestial',
'Ethereal',
'Phantom',
'Shadow',
'Sidereal',
'Planetary',
'Starry',
'Spacial',
// Scale & Magnitude
'Infinite',
'Vast',
'Boundless',
'Immense',
'Colossal',
'Titanic',
'Massive',
'Grand',
'Supreme',
'Ultimate',
'Epic',
'Enormous',
'Gigantic',
'Limitless',
'Total',
// Temporal
'Eternal',
'Ancient',
'Timeless',
'Enduring',
'Ageless',
'Immortal',
'Primal',
'Nascent',
'First',
'Elder',
'Lasting',
'Undying',
'Perpetual',
'Final',
'Prime',
// Movement & Energy
'Sidbuck',
'Swift',
'Drifting',
'Spinning',
'Surging',
'Pulsing',
'Soaring',
'Racing',
'Falling',
'Rising',
'Circling',
'Streaking',
'Hurtling',
'Floating',
'Orbiting',
'Spiraling',
// Colors of Space
'Crimson',
'Azure',
'Violet',
'Scarlet',
'Magenta',
'Turquoise',
'Indigo',
'Jade',
'Noble',
'Regal',
'Imperial',
'Royal',
'Supreme',
'Prime',
'Elite',
'Ultra',
'Mega',
'Hyper',
'Super',
'Neo',
'Cyber',
'Digital',
'Virtual',
'Sonic',
'Amber',
'Sapphire',
'Obsidian',
'Silver',
'Golden',
'Scarlet',
'Cobalt',
'Emerald',
'Ruby',
'Onyx',
'Ivory',
// Physical Properties
'Magnetic',
'Quantum',
'Thermal',
'Photonic',
'Ionic',
'Plasma',
'Spectral',
'Charged',
'Polar',
'Dense',
'Atomic',
'Nuclear',
'Laser',
'Plasma',
'Magnetic',
'Electric',
'Kinetic',
'Static',
// Atmosphere & Mystery
'Ethereal',
'Mystic',
'Phantom',
'Shadow',
'Silent',
'Distant',
'Hidden',
'Veiled',
'Fading',
'Arcane',
'Cryptic',
'Obscure',
'Dim',
'Dusky',
'Shrouded',
// Temperature & State
'Frozen',
'Burning',
'Molten',
'Volatile',
'Icy',
'Fiery',
'Cool',
'Warm',
'Cold',
'Hot',
'Searing',
'Frigid',
'Scalding',
'Chilled',
'Heated',
// Power & Force
'Mighty',
'Fierce',
'Raging',
'Wild',
'Serene',
'Tranquil',
'Harmonic',
'Resonant',
'Steady',
'Bold',
'Potent',
'Violent',
'Calm',
'Furious',
'Forceful',
// Texture & Form
'Smooth',
'Jagged',
'Fractured',
'Solid',
'Hollow',
'Curved',
'Sharp',
'Fluid',
'Rigid',
'Warped',
// Rare & Precious
'Noble',
'Pure',
'Rare',
'Pristine',
'Flawless',
'Unique',
'Exotic',
'Sacred',
'Divine',
'Hallowed',
]
const NOUNS = [
'Phoenix',
'Dragon',
'Eagle',
'Wolf',
'Lion',
'Tiger',
'Panther',
'Falcon',
'Hawk',
'Raven',
'Swan',
'Dove',
'Butterfly',
'Firefly',
'Dragonfly',
'Hummingbird',
// Stars & Stellar Objects
'Star',
'Sun',
'Pulsar',
'Quasar',
'Magnetar',
'Nova',
'Supernova',
'Hypernova',
'Neutron',
'Dwarf',
'Giant',
'Protostar',
'Blazar',
'Cepheid',
'Binary',
// Galaxies & Clusters
'Galaxy',
'Nebula',
'Cluster',
'Void',
'Filament',
'Halo',
'Bulge',
'Spiral',
'Ellipse',
'Arm',
'Disk',
'Shell',
'Remnant',
'Cloud',
'Dust',
// Planets & Moons
'Planet',
'Moon',
'World',
'Exoplanet',
'Jovian',
'Titan',
'Europa',
'Io',
'Callisto',
'Ganymede',
'Triton',
'Phobos',
'Deimos',
'Enceladus',
'Charon',
// Small Bodies
'Comet',
'Meteor',
'Star',
'Moon',
'Sun',
'Planet',
'Asteroid',
'Constellation',
'Aurora',
'Meteorite',
'Bolide',
'Fireball',
'Iceball',
'Plutino',
'Centaur',
'Trojan',
'Shard',
'Fragment',
'Debris',
'Rock',
'Ice',
// Constellations & Myths
'Orion',
'Andromeda',
'Perseus',
'Pegasus',
'Phoenix',
'Draco',
'Cygnus',
'Aquila',
'Lyra',
'Vega',
'Centaurus',
'Hydra',
'Sirius',
'Polaris',
'Altair',
// Celestial Phenomena
'Eclipse',
'Solstice',
'Equinox',
'Horizon',
'Zenith',
'Castle',
'Tower',
'Bridge',
'Garden',
'Fountain',
'Palace',
'Temple',
'Cathedral',
'Lighthouse',
'Windmill',
'Waterfall',
'Canyon',
'Valley',
'Peak',
'Ridge',
'Cliff',
'Ocean',
'River',
'Lake',
'Stream',
'Pond',
'Bay',
'Cove',
'Harbor',
'Island',
'Peninsula',
'Archipelago',
'Atoll',
'Reef',
'Lagoon',
'Fjord',
'Delta',
'Cake',
'Cookie',
'Muffin',
'Cupcake',
'Pie',
'Tart',
'Brownie',
'Donut',
'Pancake',
'Waffle',
'Croissant',
'Bagel',
'Pretzel',
'Biscuit',
'Scone',
'Crumpet',
'Thunder',
'Blizzard',
'Tornado',
'Hurricane',
'Tsunami',
'Volcano',
'Glacier',
'Avalanche',
'Aurora',
'Corona',
'Flare',
'Storm',
'Vortex',
'Tempest',
'Maelstrom',
'Whirlwind',
'Cyclone',
'Typhoon',
'Monsoon',
'Anvil',
'Hammer',
'Forge',
'Blade',
'Sword',
'Shield',
'Arrow',
'Spear',
'Crown',
'Throne',
'Scepter',
'Orb',
'Gem',
'Crystal',
'Prism',
'Spectrum',
'Beacon',
'Signal',
'Jet',
'Burst',
'Pulse',
'Wave',
'Surge',
'Tide',
'Ripple',
'Shimmer',
'Glow',
'Flash',
'Spark',
// Cosmic Structures
'Horizon',
'Zenith',
'Nadir',
'Apex',
'Meridian',
'Equinox',
'Solstice',
'Transit',
'Aphelion',
'Orbit',
'Axis',
'Pole',
'Equator',
'Limb',
'Arc',
// Space & Dimensions
'Cosmos',
'Universe',
'Dimension',
'Realm',
'Expanse',
'Infinity',
'Continuum',
'Manifold',
'Abyss',
'Ether',
'Vacuum',
'Space',
'Fabric',
'Plane',
'Domain',
// Energy & Particles
'Photon',
'Neutrino',
'Proton',
'Electron',
'Positron',
'Quark',
'Boson',
'Fermion',
'Tachyon',
'Graviton',
'Meson',
'Gluon',
'Lepton',
'Muon',
'Pion',
// Regions & Zones
'Sector',
'Quadrant',
'Zone',
'Belt',
'Ring',
'Field',
'Stream',
'Current',
'Flow',
'Circuit',
'Node',
'Core',
'Matrix',
'Network',
'System',
'Engine',
'Reactor',
'Generator',
'Dynamo',
'Catalyst',
'Nexus',
'Portal',
'Wake',
'Region',
'Frontier',
'Border',
'Edge',
'Margin',
'Rim',
// Navigation & Discovery
'Beacon',
'Signal',
'Probe',
'Voyager',
'Pioneer',
'Seeker',
'Wanderer',
'Nomad',
'Drifter',
'Scout',
'Explorer',
'Ranger',
'Surveyor',
'Sentinel',
'Watcher',
// Portals & Passages
'Gateway',
'Passage',
'Portal',
'Nexus',
'Bridge',
'Conduit',
'Channel',
'Passage',
'Rift',
'Warp',
'Fold',
'Tunnel',
'Crossing',
'Link',
'Path',
'Route',
// Core & Systems
'Core',
'Matrix',
'Lattice',
'Network',
'Circuit',
'Array',
'Reactor',
'Engine',
'Forge',
'Crucible',
'Hub',
'Node',
'Kernel',
'Center',
'Heart',
// Cosmic Objects
'Crater',
'Rift',
'Chasm',
'Canyon',
'Peak',
'Ridge',
'Basin',
'Plateau',
'Valley',
'Trench',
]
/**