Compare commits

..

4 Commits

Author SHA1 Message Date
waleed
c738a71b74 ack comments 2026-01-11 11:27:47 -08:00
waleed
80ede82ee1 fix copy-paste subflows deselecting children 2026-01-11 11:18:40 -08:00
waleed
28943eb11d ack PR comments 2026-01-10 22:04:41 -08:00
waleed
5e1c83883b fix(resize): fix subflow resize on drag, children deselected in subflow on drag 2026-01-10 21:54:59 -08:00
15 changed files with 434 additions and 1038 deletions

View File

@@ -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='sm'>
<Avatar size='xs'>
{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='md'>
<Avatar size='sm'>
{member.userImage && <AvatarImage src={member.userImage} alt={name} />}
<AvatarFallback
style={{

View File

@@ -434,10 +434,12 @@ 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
@@ -527,7 +529,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='md'>
<Avatar size='sm'>
{member.userImage && (
<AvatarImage src={member.userImage} alt={name} />
)}
@@ -581,7 +583,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='md'>
<Avatar size='sm'>
<AvatarFallback
style={{ background: getUserColor(email) }}
className='border-0 text-white'

View File

@@ -1,41 +1,12 @@
'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 {
/**
@@ -82,14 +53,6 @@ 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
@@ -120,21 +83,11 @@ 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
@@ -181,74 +134,23 @@ 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) {
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()
}, [])
// Section visibility for divider logic
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasEditSection =
(showRename && onRename) ||
(showCreate && onCreate) ||
(showCreateFolder && onCreateFolder) ||
(showColorChange && onColorChange)
(showRename && onRename) || (showCreate && onCreate) || (showCreateFolder && onCreateFolder)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
return (
@@ -268,21 +170,10 @@ export function ContextMenu({
height: '1px',
}}
/>
<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 />
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Navigation actions */}
{showOpenInNewTab && onOpenInNewTab && (
<PopoverItem
rootOnly
onClick={() => {
onOpenInNewTab()
onClose()
@@ -291,12 +182,11 @@ export function ContextMenu({
Open in new tab
</PopoverItem>
)}
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider rootOnly />}
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider />}
{/* Edit and create actions */}
{showRename && onRename && (
<PopoverItem
rootOnly
disabled={disableRename}
onClick={() => {
onRename()
@@ -308,7 +198,6 @@ export function ContextMenu({
)}
{showCreate && onCreate && (
<PopoverItem
rootOnly
disabled={disableCreate}
onClick={() => {
onCreate()
@@ -320,7 +209,6 @@ export function ContextMenu({
)}
{showCreateFolder && onCreateFolder && (
<PopoverItem
rootOnly
disabled={disableCreateFolder}
onClick={() => {
onCreateFolder()
@@ -330,72 +218,11 @@ 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 rootOnly />}
{hasEditSection && hasCopySection && <PopoverDivider />}
{showDuplicate && onDuplicate && (
<PopoverItem
rootOnly
disabled={disableDuplicate}
onClick={() => {
onDuplicate()
@@ -407,7 +234,6 @@ export function ContextMenu({
)}
{showExport && onExport && (
<PopoverItem
rootOnly
disabled={disableExport}
onClick={() => {
onExport()
@@ -419,9 +245,8 @@ export function ContextMenu({
)}
{/* Destructive action */}
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider rootOnly />}
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider />}
<PopoverItem
rootOnly
disabled={disableDelete}
onClick={() => {
onDelete()

View File

@@ -3,9 +3,8 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
import { ChevronRight, Folder, FolderOpen } 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'
@@ -24,7 +23,10 @@ import {
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import type { FolderTreeNode } from '@/stores/folders/types'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
import {
generateCreativeWorkflowName,
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
const logger = createLogger('FolderItem')
@@ -171,7 +173,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
menuRef,
handleContextMenu,
closeMenu,
preventDismiss,
} = useContextMenu()
// Rename hook
@@ -241,40 +242,6 @@ 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
@@ -336,22 +303,12 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
spellCheck='false'
/>
) : (
<>
<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>
</>
<span
className='truncate font-medium text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
onDoubleClick={handleDoubleClick}
>
{folder.name}
</span>
)}
</div>

View File

@@ -1,24 +1,15 @@
'use client'
import { type CSSProperties, useEffect, useMemo } from 'react'
import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { 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).
@@ -39,29 +30,45 @@ interface UserAvatarProps {
}
/**
* Individual user avatar using emcn Avatar component.
* Individual user avatar with error handling for image loading.
* 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 = (
<Avatar size='xs' style={{ zIndex: index + 1 } as CSSProperties}>
{user.avatarUrl && (
<AvatarImage
<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
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
)}
<AvatarFallback
style={{ background: color }}
className='border-0 font-semibold text-[7px] text-white'
>
{initials}
</AvatarFallback>
</Avatar>
</div>
)
if (user.userName) {
@@ -85,26 +92,14 @@ function UserAvatar({ user, index }: UserAvatarProps) {
* @param props - Component props
* @returns Avatar stack for workflow presence
*/
export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: AvatarsProps) {
const { presenceUsers, currentWorkflowId } = useSocket()
const { data: session } = useSession()
const currentUserId = session?.user?.id
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
/**
* 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.
* Only show presence for the currently active workflow
* Filter out the current user from the list
*/
const workflowUsers = useMemo(() => {
if (currentWorkflowId !== workflowId) {
@@ -127,6 +122,7 @@ export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
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') {
@@ -139,25 +135,26 @@ export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
}
return (
<div className='-space-x-1 flex items-center'>
<div className='-space-x-1 ml-[-8px] flex items-center'>
{visibleUsers.map((user, index) => (
<UserAvatar key={user.socketId} user={user} index={index} />
))}
{overflowCount > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Avatar size='xs' style={{ zIndex: 0 } as CSSProperties}>
<AvatarFallback className='border-0 bg-[#404040] font-semibold text-[7px] text-white'>
+{overflowCount}
</AvatarFallback>
</Avatar>
<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>
</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>
)
}

View File

@@ -2,7 +2,6 @@
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'
@@ -109,16 +108,6 @@ 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
*
@@ -153,38 +142,8 @@ 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
@@ -192,46 +151,39 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
*/
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
captureSelectionState()
handleContextMenuBase(e)
},
[captureSelectionState, handleContextMenuBase]
)
// Check current selection state at time of right-click
const { selectedWorkflows: currentSelection, selectOnly } = useFolderStore.getState()
const isCurrentlySelected = currentSelection.has(workflow.id)
/**
* 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
// If this workflow is not in the current selection, select only this workflow
if (!isCurrentlySelected) {
selectOnly(workflow.id)
}
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)
// 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
handleContextMenuBase(e)
},
[isContextMenuOpen, closeMenu, captureSelectionState, handleContextMenuBase]
[workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
)
// Rename hook
@@ -357,17 +309,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
)}
</div>
{!isEditing && (
<>
<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>
</>
<Avatars workflowId={workflow.id} maxVisible={3} onPresenceChange={setHasAvatars} />
)}
</Link>
@@ -382,17 +324,13 @@ 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}
/>

View File

@@ -27,8 +27,6 @@ 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
@@ -57,14 +55,6 @@ 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
*/
@@ -72,11 +62,6 @@ 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()
}
@@ -99,6 +84,5 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
menuRef,
handleContextMenu,
closeMenu,
preventDismiss,
}
}

View File

@@ -1,11 +1,13 @@
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 } from '@/stores/workflows/registry/utils'
import {
generateCreativeWorkflowName,
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
const logger = createLogger('useWorkflowOperations')

View File

@@ -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')

View File

@@ -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-3.5 w-3.5',
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-10 w-10',
xs: 'h-6 w-6',
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
},
},
defaultVariants: {
@@ -37,10 +37,10 @@ const avatarStatusVariants = cva(
away: 'bg-[#f59e0b]',
},
size: {
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',
xs: 'h-2 w-2',
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-3.5 w-3.5',
},
},
defaultVariants: {

View File

@@ -52,7 +52,6 @@
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'
@@ -167,9 +166,6 @@ 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)
@@ -212,24 +208,12 @@ 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) => {
@@ -262,8 +246,6 @@ const Popover: React.FC<PopoverProps> = ({
colorScheme,
searchQuery,
setSearchQuery,
lastHoveredItem,
setLastHoveredItem,
}),
[
openFolder,
@@ -275,15 +257,12 @@ const Popover: React.FC<PopoverProps> = ({
size,
colorScheme,
searchQuery,
lastHoveredItem,
]
)
return (
<PopoverContext.Provider value={contextValue}>
<PopoverPrimitive.Root open={open} {...props}>
{children}
</PopoverPrimitive.Root>
<PopoverPrimitive.Root {...props}>{children}</PopoverPrimitive.Root>
</PopoverContext.Provider>
)
}
@@ -517,17 +496,7 @@ export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
*/
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
(
{
className,
active,
rootOnly,
disabled,
showCheck = false,
children,
onClick,
onMouseEnter,
...props
},
{ className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
ref
) => {
const context = React.useContext(PopoverContext)
@@ -545,12 +514,6 @@ 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(
@@ -566,7 +529,6 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
aria-selected={active}
aria-disabled={disabled}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
{...props}
>
{children}
@@ -627,150 +589,44 @@ 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,
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)
({ className, id, title, icon, onOpen, onSelect, children, active, ...props }, ref) => {
const { openFolder, currentFolder, isInFolder, variant, size, colorScheme } =
usePopoverContext()
// 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 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?.()
openFolder(id, title, onOpen, onSelect)
}
return (
<>
<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
)}
</>
<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>
)
}
)
@@ -809,10 +665,7 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
className
)}
role='button'
onClick={(e) => {
e.stopPropagation()
closeFolder()
}}
onClick={closeFolder}
{...props}
>
<ChevronLeft className={STYLES.size[size].icon} />

View File

@@ -1,7 +1,6 @@
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,
@@ -9,7 +8,10 @@ import {
} from '@/hooks/queries/utils/optimistic-mutation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
import {
generateCreativeWorkflowName,
getNextWorkflowColor,
} from '@/stores/workflows/registry/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

View File

@@ -1,75 +0,0 @@
/**
* 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
}

View File

@@ -3,7 +3,6 @@ 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 {
@@ -12,6 +11,7 @@ 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'

View File

@@ -1,410 +1,321 @@
// Cosmos-themed adjectives and nouns for creative workflow names (max 9 chars each)
// 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
const ADJECTIVES = [
// Light & Luminosity
'Radiant',
'Luminous',
'Blazing',
'Glowing',
'Bright',
'Gleaming',
'Shining',
'Lustrous',
'Flaring',
'Vivid',
'Dazzling',
'Beaming',
'Brilliant',
'Lit',
'Ablaze',
// Celestial Descriptors
'Stellar',
'Crystal',
'Golden',
'Silver',
'Mystic',
'Cosmic',
'Astral',
'Galactic',
'Nebular',
'Orbital',
'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',
'Radiant',
'Quantum',
'Stellar',
'Lunar',
'Solar',
'Starlit',
'Heavenly',
'Celestial',
'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
'Ethereal',
'Phantom',
'Shadow',
'Crimson',
'Azure',
'Violet',
'Indigo',
'Amber',
'Sapphire',
'Obsidian',
'Silver',
'Golden',
'Scarlet',
'Cobalt',
'Emerald',
'Ruby',
'Onyx',
'Ivory',
// Physical Properties
'Magnetic',
'Quantum',
'Thermal',
'Photonic',
'Ionic',
'Plasma',
'Spectral',
'Charged',
'Polar',
'Dense',
'Magenta',
'Turquoise',
'Indigo',
'Jade',
'Noble',
'Regal',
'Imperial',
'Royal',
'Supreme',
'Prime',
'Elite',
'Ultra',
'Mega',
'Hyper',
'Super',
'Neo',
'Cyber',
'Digital',
'Virtual',
'Sonic',
'Atomic',
'Nuclear',
'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',
'Laser',
'Plasma',
'Magnetic',
]
const NOUNS = [
// Stars & Stellar Objects
'Star',
'Sun',
'Pulsar',
'Quasar',
'Magnetar',
'Nova',
'Supernova',
'Hypernova',
'Neutron',
'Dwarf',
'Giant',
'Protostar',
'Blazar',
'Cepheid',
'Binary',
// Galaxies & Clusters
'Phoenix',
'Dragon',
'Eagle',
'Wolf',
'Lion',
'Tiger',
'Panther',
'Falcon',
'Hawk',
'Raven',
'Swan',
'Dove',
'Butterfly',
'Firefly',
'Dragonfly',
'Hummingbird',
'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',
'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',
'Constellation',
'Aurora',
'Corona',
'Flare',
'Storm',
'Vortex',
'Jet',
'Burst',
'Pulse',
'Wave',
'Ripple',
'Shimmer',
'Glow',
'Flash',
'Spark',
// Cosmic Structures
'Eclipse',
'Solstice',
'Equinox',
'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',
'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',
'Portal',
'Nexus',
'Castle',
'Tower',
'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',
'Garden',
'Fountain',
'Palace',
'Temple',
'Cathedral',
'Lighthouse',
'Windmill',
'Waterfall',
'Canyon',
'Valley',
'Peak',
'Ridge',
'Basin',
'Plateau',
'Valley',
'Trench',
'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',
'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',
'Pulse',
'Wave',
'Surge',
'Tide',
'Current',
'Flow',
'Circuit',
'Node',
'Core',
'Matrix',
'Network',
'System',
'Engine',
'Reactor',
'Generator',
'Dynamo',
'Catalyst',
'Nexus',
'Portal',
'Gateway',
'Passage',
'Conduit',
'Channel',
]
/**