diff --git a/frontend/src/lib/components/sidebar/MenuButton.svelte b/frontend/src/lib/components/sidebar/MenuButton.svelte index 9d2bd8dd48..72cde81f9d 100644 --- a/frontend/src/lib/components/sidebar/MenuButton.svelte +++ b/frontend/src/lib/components/sidebar/MenuButton.svelte @@ -2,6 +2,7 @@ export const sidebarClasses = { text: 'text-primary-inverse dark:text-primary data-[light-mode=true]:text-primary text-xs font-normal', selectedText: 'text-emphasis-inverse dark:text-emphasis text-xs font-normal', + sublabelText: 'text-secondary-inverse dark:text-secondary text-2xs font-normal', hoverBg: 'transition-colors hover:bg-surface-hover-inverse dark:hover:bg-surface-hover data-[light-mode=true]:hover:bg-surface-hover' } @@ -20,6 +21,7 @@ aiId?: string | undefined aiDescription?: string | undefined label?: string | undefined + sublabel?: string | undefined icon?: any | undefined iconClasses?: string | null iconProps?: any | null @@ -39,6 +41,7 @@ aiId = undefined, aiDescription = undefined, label = undefined, + sublabel = undefined, icon = undefined, iconClasses = null, iconProps = null, @@ -91,41 +94,62 @@ {href} data-light-mode={lightMode} class={twMerge( - 'group flex items-center px-2 py-2 font-light rounded-md h-8 gap-3 w-full', + 'group flex items-center px-2 py-2 font-light rounded-md gap-2 w-full', sidebarClasses.hoverBg, - color ? 'border-4' : '', 'transition-all relative', + sublabel ? 'h-10' : 'h-8', classNames )} - style={color ? `border-color: ${color}; padding: 0 calc(0.5rem - 4px);` : ''} use:conditionalMelt={trigger} title={isCollapsed ? undefined : label} {...$trigger} > {#if icon} {@const SvelteComponent = icon} - +
+ +
{/if} - {#if !isCollapsed && label} - - {label} - - {shortcut} - - - {/if} +
+ {#if !isCollapsed && label} +
+ {label} + + {shortcut} + +
+ {/if} + + {#if sublabel} +
{sublabel}
+ {/if} +
{#if isCollapsed && notificationsCount > 0}
diff --git a/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte b/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte index 0c0297c5bb..c7894c3d6c 100644 --- a/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte +++ b/frontend/src/lib/components/sidebar/MultiplayerMenu.svelte @@ -84,7 +84,7 @@ {#if connected}
-
Live activity
+
Live activity
{#each Object.entries($awarenessStore ?? {}) as [user, url]}
@@ -97,8 +97,8 @@ >
- {user} - {showActivity(url)} + {user} + {showActivity(url)}
{/each} diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index 7b1503e027..50c61604a2 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -11,7 +11,7 @@ } from '$lib/stores' import { Building, Plus, Settings, GitFork } from 'lucide-svelte' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' - import { Menu, MenuItem } from '$lib/components/meltComponents' + import { Menu, MenuItem, Tooltip } from '$lib/components/meltComponents' import { goto } from '$lib/navigation' import { base } from '$lib/base' import { page } from '$app/stores' @@ -23,6 +23,7 @@ import { twMerge } from 'tailwind-merge' import type { MenubarBuilders } from '@melt-ui/svelte' import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy' + import { getContrastTextColor } from '$lib/utils' interface Props { isCollapsed?: boolean @@ -66,12 +67,6 @@ } } - // Helper function to check if a workspace is forked - function isForkedWorkspace(workspaceId: string): boolean { - if (!$userWorkspaces) return false - return $userWorkspaces.some((w) => w.id === workspaceId && w.parent_workspace_id != null) - } - function getForkedWorkspace(workspaceId: string) { if (!$userWorkspaces) return undefined return $userWorkspaces.find((w) => w.id === workspaceId && w.parent_workspace_id != null) @@ -87,43 +82,56 @@ if (!$userWorkspaces) return [] return buildWorkspaceHierarchy($userWorkspaces) }) + + const itemClass = + 'text-primary flex flex-row gap-2 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' -{#if isForkedWorkspace($workspaceStore ?? '') && !isCollapsed} - {@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')} - {@const parentWorkspace = forkedWorkspace - ? getParentWorkspace(forkedWorkspace.parent_workspace_id!) - : null} - - {#snippet triggr({ trigger })} -
- - {parentWorkspace?.name ?? ''} -
- {/snippet} -
-{/if} +{#snippet workspaceIcon( + workspaceColor: string | undefined, + isForked: boolean, + parentName: string | undefined +)} + {@const iconColor = getContrastTextColor(workspaceColor)} +
+ {#if isForked} + + {#snippet text()} + {#if isForked && parentName} + Fork of {parentName} + {/if} + {/snippet} + + + {:else} + + {/if} +
+{/snippet} + {#snippet triggr({ trigger })} {@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')} {@const parentWorkspace = forkedWorkspace ? getParentWorkspace(forkedWorkspace.parent_workspace_id!) : null} + {@const iconColor = getContrastTextColor($workspaceColor)} {#if forkedWorkspace && parentWorkspace} -
- -
+ {:else}
{#each groupedWorkspaces() as { workspace, depth, isForked, parentName }} + {@const isSelected = $workspaceStore === workspace.id}
-
- {#if isForked} - - {:else} - - {/if} +
+ {@render workspaceIcon(workspace.color, isForked, parentName)}
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
{workspace.id}
- {#if isForked && parentName} -
- Fork of {parentName} -
- {/if}
- {#if workspace.color} -
- {/if}
{/each}
{#if (isCloudHosted() || $superadmin) && !strictWorkspaceSelect} {/if} {#if !strictWorkspaceSelect && !isCloudHosted()} {/if} {#if !strictWorkspaceSelect} @@ -228,10 +211,7 @@ clearWorkspaceFromStorage()} - class={twMerge( - 'text-primary block px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} + class={itemClass} {item} > All workspaces @@ -240,14 +220,7 @@ {/if} {#if ($userStore?.is_admin || $superadmin) && !strictWorkspaceSelect}
- + Workspace settings diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index ee78419583..88309b79a1 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -862,6 +862,150 @@ export function isValidHexColor(color: string): boolean { return /^#(([A-F0-9]{2}){3,4}|[A-F0-9]{3})$/i.test(color) } +/** + * Calculates the relative luminance of a color according to WCAG 2.1 + * @param r Red component (0-255) + * @param g Green component (0-255) + * @param b Blue component (0-255) + * @returns Relative luminance value (0-1) + */ +function getRelativeLuminance(r: number, g: number, b: number): number { + const [rs, gs, bs] = [r, g, b].map((val) => { + val = val / 255 + return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4) + }) + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs +} + +/** + * Converts hex color to HSL + * @param hex Hex color string (e.g., "#FF0000") + * @returns Array of [hue (0-360), saturation (0-100), lightness (0-100)] + */ +function hexToHsl(hex: string): [number, number, number] { + // Normalize hex color + let normalizedHex = hex.replace('#', '') + if (normalizedHex.length === 3) { + normalizedHex = normalizedHex + .split('') + .map((char) => char + char) + .join('') + } + + const r = parseInt(normalizedHex.substring(0, 2), 16) / 255 + const g = parseInt(normalizedHex.substring(2, 4), 16) / 255 + const b = parseInt(normalizedHex.substring(4, 6), 16) / 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + let h = 0 + let s = 0 + const l = (max + min) / 2 + + if (max !== min) { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + + switch (max) { + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) / 6 + break + case g: + h = ((b - r) / d + 2) / 6 + break + case b: + h = ((r - g) / d + 4) / 6 + break + } + } + + return [h * 360, s * 100, l * 100] +} + +/** + * Converts HSL to hex color + * @param h Hue (0-360) + * @param s Saturation (0-100) + * @param l Lightness (0-100) + * @returns Hex color string + */ +function hslToHex(h: number, s: number, l: number): string { + h = h / 360 + s = s / 100 + l = l / 100 + + let r: number, g: number, b: number + + if (s === 0) { + r = g = b = l // Achromatic + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1 + if (t > 1) t -= 1 + if (t < 1 / 6) return p + (q - p) * 6 * t + if (t < 1 / 2) return q + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 + return p + } + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + r = hue2rgb(p, q, h + 1 / 3) + g = hue2rgb(p, q, h) + b = hue2rgb(p, q, h - 1 / 3) + } + + const toHex = (c: number) => { + const hex = Math.round(c * 255).toString(16) + return hex.length === 1 ? '0' + hex : hex + } + + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +/** + * Generates a text color with the same hue as the background but adjusted lightness for good contrast + * @param backgroundColor Hex color string (e.g., "#FF0000" or "#F00") + * @returns Hex color string with same hue but good contrast, or undefined if invalid + */ +export function getContrastTextColor( + backgroundColor: string | null | undefined +): string | undefined { + if (!backgroundColor || !isValidHexColor(backgroundColor)) { + return undefined + } + + // Normalize hex color + let hex = backgroundColor.replace('#', '') + if (hex.length === 3) { + hex = hex + .split('') + .map((char) => char + char) + .join('') + } + + // Parse RGB components and calculate background luminance + const r = parseInt(hex.substring(0, 2), 16) + const g = parseInt(hex.substring(2, 4), 16) + const b = parseInt(hex.substring(4, 6), 16) + const bgLuminance = getRelativeLuminance(r, g, b) + + // Convert to HSL to extract hue + const [hue] = hexToHsl(backgroundColor) + + // Determine if background is light or dark + const isLightBackground = bgLuminance > 0.5 + + // Use fixed saturation and lightness based on background lightness + // For light backgrounds: use dark text (low lightness, high saturation) + // For dark backgrounds: use light text (high lightness, high saturation) + const saturation = 70 // Fixed saturation for good readability + const lightness = isLightBackground ? 25 : 85 // Dark for light bg, light for dark bg + + // Generate the color with the same hue but adjusted saturation and lightness + return hslToHex(hue, saturation, lightness) +} + export function sortObject(o: T & object): T { return Object.keys(o) .sort() diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index c9bd9640e7..fbca74b36c 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -161,18 +161,20 @@ {/if} -

- Workspaces{#if loading}{/if} -

+
+

+ Workspaces{#if loading}{/if} +

- {#if $superadmin} -
- -
- {/if} + {#if $superadmin} +
+ +
+ {/if} +
{#if adminsInstance}
{/if} - {workspace.id} - + {workspace.id} - {workspace.name} as {workspace.username} @@ -233,12 +235,6 @@ {#if workspace.disabled} (user disabled in this workspace) {/if} - {#if isForked && parentName} - -
- Fork of {parentName} -
- {/if} {#if $superadmin && workspace['deleted']}