diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx index 63c17c3af5..034f028290 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx @@ -163,7 +163,7 @@ function AddMembersModal({ className='flex items-center gap-[10px] rounded-[4px] px-[8px] py-[6px] hover:bg-[var(--surface-2)]' > - + {member.user?.image && ( )} @@ -663,7 +663,7 @@ export function AccessControl() { return (
- + {member.userImage && } } - // 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 (
- + {member.userImage && ( )} @@ -583,7 +581,7 @@ export function CredentialSets() { return (
- + 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) => { + if (e.key === 'Enter') { + e.preventDefault() + handleHexSubmit() + } + }, + [handleHexSubmit] + ) + + const handleHexChange = useCallback((e: React.ChangeEvent) => { + 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) => { + 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', }} /> - + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + {/* Back button - shown only when in a folder */} + + {/* Navigation actions */} {showOpenInNewTab && onOpenInNewTab && ( { onOpenInNewTab() onClose() @@ -182,11 +291,12 @@ export function ContextMenu({ Open in new tab )} - {hasNavigationSection && (hasEditSection || hasCopySection) && } + {hasNavigationSection && (hasEditSection || hasCopySection) && } {/* Edit and create actions */} {showRename && onRename && ( { onRename() @@ -198,6 +308,7 @@ export function ContextMenu({ )} {showCreate && onCreate && ( { onCreate() @@ -209,6 +320,7 @@ export function ContextMenu({ )} {showCreateFolder && onCreateFolder && ( { onCreateFolder() @@ -218,11 +330,72 @@ export function ContextMenu({ Create folder )} + {showColorChange && onColorChange && ( + +
+ {/* Preset colors */} +
+ {WORKFLOW_COLORS.map(({ color, name }) => ( +
+ + {/* Hex input */} +
+
+ e.stopPropagation()} + className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase focus:outline-none' + /> + +
+
+ + )} {/* Copy and export actions */} - {hasEditSection && hasCopySection && } + {hasEditSection && hasCopySection && } {showDuplicate && onDuplicate && ( { onDuplicate() @@ -234,6 +407,7 @@ export function ContextMenu({ )} {showExport && onExport && ( { onExport() @@ -245,8 +419,9 @@ export function ContextMenu({ )} {/* Destructive action */} - {(hasNavigationSection || hasEditSection || hasCopySection) && } + {(hasNavigationSection || hasEditSection || hasCopySection) && } { onDelete() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index ee65207b47..1dc249088d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -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) => { + 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 ( <>
) : ( - - {folder.name} - + <> + + {folder.name} + + + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx index 685787bc92..506b9a6e24 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx @@ -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 = ( -
- {hasAvatar && user.avatarUrl ? ( - + {user.avatarUrl && ( + setImageError(true)} /> - ) : ( - initials )} -
+ + {initials} + + ) 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 ( -
- {visibleUsers.map((user, index) => ( - - ))} - +
{overflowCount > 0 && ( -
- +{overflowCount} -
+ + + +{overflowCount} + +
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
)} + + {visibleUsers.map((user, index) => ( + 0 ? index + 1 : index} /> + ))}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index ffa481fa1b..2665ed3b24 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -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) + captureSelectionState() + handleContextMenuBase(e) + }, + [captureSelectionState, handleContextMenuBase] + ) - const workflowIds = - finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id] + /** + * Handle more button pointerdown - prevents click-outside dismissal when toggling + */ + const handleMorePointerDown = useCallback(() => { + if (isContextMenuOpen) { + preventDismiss() + } + }, [isContextMenuOpen, preventDismiss]) - const workflowNames = workflowIds - .map((id) => workflows[id]?.name) - .filter((name): name is string => !!name) + /** + * Handle more button click - toggles context menu at button position + */ + const handleMoreClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() - // Store in ref so it persists even if selection changes - capturedSelectionRef.current = { - workflowIds, - workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0], + // Toggle: close if open, open if closed + if (isContextMenuOpen) { + closeMenu() + return } - // Check if the captured selection can be deleted - setCanDeleteCaptured(canDeleteWorkflows(workflowIds)) - - // If already selected with multiple selections, keep all selections - handleContextMenuBase(e) + 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) }, - [workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows] + [isContextMenuOpen, closeMenu, captureSelectionState, handleContextMenuBase] ) // Rename hook @@ -309,7 +357,17 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf )}
{!isEditing && ( - + <> + + + )} @@ -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} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts index 13a6291e34..35b8546b2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts @@ -27,6 +27,8 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) { const [isOpen, setIsOpen] = useState(false) const [position, setPosition] = useState({ x: 0, y: 0 }) const menuRef = useRef(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, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts index f88b1cf118..6e9bed0441 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts @@ -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') diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts index 6ead0955e0..5d39e8739e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts @@ -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') diff --git a/apps/sim/components/emcn/components/avatar/avatar.tsx b/apps/sim/components/emcn/components/avatar/avatar.tsx index e41c40a9fa..9500f7ecbe 100644 --- a/apps/sim/components/emcn/components/avatar/avatar.tsx +++ b/apps/sim/components/emcn/components/avatar/avatar.tsx @@ -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: { diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index 0aa8237cee..d80841d677 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -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(null) @@ -208,12 +212,24 @@ const Popover: React.FC = ({ variant = 'default', size = 'md', colorScheme = 'default', + open, ...props }) => { const [currentFolder, setCurrentFolder] = React.useState(null) const [folderTitle, setFolderTitle] = React.useState(null) const [onFolderSelect, setOnFolderSelect] = React.useState<(() => void) | null>(null) const [searchQuery, setSearchQuery] = React.useState('') + const [lastHoveredItem, setLastHoveredItem] = React.useState(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, onSelect?: () => void) => { @@ -246,6 +262,8 @@ const Popover: React.FC = ({ colorScheme, searchQuery, setSearchQuery, + lastHoveredItem, + setLastHoveredItem, }), [ openFolder, @@ -257,12 +275,15 @@ const Popover: React.FC = ({ size, colorScheme, searchQuery, + lastHoveredItem, ] ) return ( - {children} + + {children} + ) } @@ -496,7 +517,17 @@ export interface PopoverItemProps extends React.HTMLAttributes { */ const PopoverItem = React.forwardRef( ( - { 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( onClick?.(e) } + const handleMouseEnter = (e: React.MouseEvent) => { + // Clear last hovered item to close any open hover submenus + context?.setLastHoveredItem(null) + onMouseEnter?.(e) + } + return (
( aria-selected={active} aria-disabled={disabled} onClick={handleClick} + onMouseEnter={handleMouseEnter} {...props} > {children} @@ -589,44 +627,150 @@ export interface PopoverFolderProps extends Omit( - ({ 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(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 handleClickOpen = () => { + openFolder(id, title, onOpen, onSelect) + } + const handleClick = (e: React.MouseEvent) => { e.stopPropagation() - openFolder(id, title, onOpen, onSelect) + 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 ( -
- {icon} - {title} - -
+ <> +
+ {icon} + {title} + +
+ + {/* Hover submenu - rendered as a portal to escape overflow clipping */} + {isHoverOpen && + typeof document !== 'undefined' && + createPortal( +
+ {children} +
, + document.body + )} + ) } ) @@ -665,7 +809,10 @@ const PopoverBackButton = React.forwardRef { + e.stopPropagation() + closeFolder() + }} {...props} > diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 881a0a9939..eaabbab66e 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -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' diff --git a/apps/sim/lib/workflows/colors.ts b/apps/sim/lib/workflows/colors.ts new file mode 100644 index 0000000000..f4b34468ad --- /dev/null +++ b/apps/sim/lib/workflows/colors.ts @@ -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 +} diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 6ba54ec1fa..33d2c0d9ef 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -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' diff --git a/apps/sim/stores/workflows/registry/utils.ts b/apps/sim/stores/workflows/registry/utils.ts index e3a91c1a97..8be102e4a6 100644 --- a/apps/sim/stores/workflows/registry/utils.ts +++ b/apps/sim/stores/workflows/registry/utils.ts @@ -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 = [ + // Light & Luminosity + 'Radiant', + 'Luminous', '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', - 'Radiant', - 'Quantum', + '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', + 'Aurora', + 'Corona', + 'Flare', + 'Storm', + 'Vortex', + 'Jet', + 'Burst', + 'Pulse', + 'Wave', + 'Ripple', + 'Shimmer', + 'Glow', + 'Flash', + 'Spark', + // Cosmic Structures 'Horizon', 'Zenith', - 'Castle', - 'Tower', - 'Bridge', - 'Garden', - 'Fountain', - 'Palace', - 'Temple', - 'Cathedral', - 'Lighthouse', - 'Windmill', - 'Waterfall', - 'Canyon', - 'Valley', - 'Peak', - 'Ridge', - 'Cliff', - 'Ocean', - 'River', - 'Lake', + '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', - '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', + 'Current', + 'Wake', + 'Region', + 'Frontier', + 'Border', + 'Edge', + 'Margin', + 'Rim', + // Navigation & Discovery 'Beacon', 'Signal', - 'Pulse', - 'Wave', - 'Surge', - 'Tide', - 'Current', - 'Flow', - 'Circuit', - 'Node', + 'Probe', + 'Voyager', + 'Pioneer', + 'Seeker', + 'Wanderer', + 'Nomad', + 'Drifter', + 'Scout', + 'Explorer', + 'Ranger', + 'Surveyor', + 'Sentinel', + 'Watcher', + // Portals & Passages + 'Gateway', + 'Portal', + 'Nexus', + 'Bridge', + 'Conduit', + 'Channel', + 'Passage', + 'Rift', + 'Warp', + 'Fold', + 'Tunnel', + 'Crossing', + 'Link', + 'Path', + 'Route', + // Core & Systems 'Core', 'Matrix', + 'Lattice', 'Network', - 'System', - 'Engine', + 'Circuit', + 'Array', 'Reactor', - 'Generator', - 'Dynamo', - 'Catalyst', - 'Nexus', - 'Portal', - 'Gateway', - 'Passage', - 'Conduit', - 'Channel', + 'Engine', + 'Forge', + 'Crucible', + 'Hub', + 'Node', + 'Kernel', + 'Center', + 'Heart', + // Cosmic Objects + 'Crater', + 'Rift', + 'Chasm', + 'Canyon', + 'Peak', + 'Ridge', + 'Basin', + 'Plateau', + 'Valley', + 'Trench', ] /**