fix(frontend): improve workspace picker menu UI (#7491)

* Improve sidebar workspace picker

* nit

* nit
This commit is contained in:
Guilhem
2026-01-05 22:23:51 +00:00
committed by GitHub
parent b9d2adb704
commit ca285a189f
5 changed files with 270 additions and 133 deletions

View File

@@ -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}
<SvelteComponent
size={16}
class={twMerge('flex-shrink-0', sidebarClasses.text, 'transition-colors', iconClasses)}
{...iconProps}
/>
<div
style="background-color: {color}"
class={twMerge('rounded-full center-center', color ? 'p-1 -ml-1' : '')}
>
<SvelteComponent
size={16}
class={twMerge('flex-shrink-0', sidebarClasses.text, 'transition-colors', iconClasses)}
{...iconProps}
/>
</div>
{/if}
{#if !isCollapsed && label}
<span
class={twMerge(
'whitespace-pre truncate',
sidebarClasses.text,
'transition-all',
classNames
)}
>
{label}
<span class="pl-2 text-xs dark:text-secondary light:text-secondary-inverse font-semibold">
{shortcut}
</span>
</span>
{/if}
<div class="flex flex-col text-left grow min-w-0">
{#if !isCollapsed && label}
<div
class={twMerge(
'whitespace-pre truncate w-full',
sidebarClasses.text,
'transition-all',
classNames
)}
title={label}
>
{label}
<span
class="pl-2 text-xs dark:text-secondary light:text-secondary-inverse font-semibold"
>
{shortcut}
</span>
</div>
{/if}
{#if sublabel}
<div
class={twMerge(
'whitespace-pre truncate w-full',
sidebarClasses.sublabelText,
'transition-all',
classNames
)}
title={sublabel}>{sublabel}</div
>
{/if}
</div>
{#if isCollapsed && notificationsCount > 0}
<div class="absolute translate-x-1/2 translate-y-1/2 -top-2 right-1 flex h-fit w-fit">

View File

@@ -84,7 +84,7 @@
{#if connected}
<div class="divide-gray-100 border-t" role="none">
<div class="px-2.5 text-xs font-semibold mt-1">Live activity</div>
<div class="px-2 text-xs text-secondary font-normal mt-1">Live activity</div>
<div class="py-1 flex flex-col gap-y-1 max-h-48 overflow-auto" transition:slide>
{#each Object.entries($awarenessStore ?? {}) as [user, url]}
<div class="inline-flex gap-2 px-2 items-center">
@@ -97,8 +97,8 @@
>
</span>
<div class="flex flex-col">
<span class="text-sm text-primary truncate">{user}</span>
<span class="text-xs text-primary truncate">{showActivity(url)}</span>
<span class="text-xs text-primary truncate">{user}</span>
<span class="text-2xs text-secondary truncate">{showActivity(url)}</span>
</div>
</div>
{/each}

View File

@@ -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'
</script>
{#if isForkedWorkspace($workspaceStore ?? '') && !isCollapsed}
{@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')}
{@const parentWorkspace = forkedWorkspace
? getParentWorkspace(forkedWorkspace.parent_workspace_id!)
: null}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<div class="group flex items-center px-2 py-2 font-light rounded-md h-8 gap-3 w-full text-xs">
<Building size={12} class="text-primary" />
<span class="text-xs text-primary"> {parentWorkspace?.name ?? ''} </span>
</div>
{/snippet}
</Menu>
{/if}
{#snippet workspaceIcon(
workspaceColor: string | undefined,
isForked: boolean,
parentName: string | undefined
)}
{@const iconColor = getContrastTextColor(workspaceColor)}
<div style="background-color: {workspaceColor}" class="rounded-full p-1.5 center-center">
{#if isForked}
<Tooltip>
{#snippet text()}
{#if isForked && parentName}
Fork of {parentName}
{/if}
{/snippet}
<GitFork size={14} class="flex-shrink-0" style="color: {iconColor}" />
</Tooltip>
{:else}
<Building size={14} style="color: {iconColor}" />
{/if}
</div>
{/snippet}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
{@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')}
{@const parentWorkspace = forkedWorkspace
? getParentWorkspace(forkedWorkspace.parent_workspace_id!)
: null}
{@const iconColor = getContrastTextColor($workspaceColor)}
{#if forkedWorkspace && parentWorkspace}
<div class={isCollapsed ? '' : 'pl-6'}>
<MenuButton
class="!text-xs"
icon={GitFork}
label={removePrefix($workspaceStore ?? '', 'wm-fork-')}
{isCollapsed}
color={$workspaceColor}
{trigger}
/>
</div>
<MenuButton
class="!text-xs"
icon={GitFork}
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
label={removePrefix($workspaceStore ?? '', 'wm-fork-')}
sublabel={parentWorkspace?.name ? `Fork of ${parentWorkspace.name}` : undefined}
{isCollapsed}
color={$workspaceColor}
{trigger}
/>
{:else}
<MenuButton
class="!text-xs"
icon={Building}
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
label={$workspaceStore ?? ''}
{isCollapsed}
color={$workspaceColor}
@@ -136,12 +144,13 @@
<div class="divide-y" role="none">
<div class="py-1">
{#each groupedWorkspaces() as { workspace, depth, isForked, parentName }}
{@const isSelected = $workspaceStore === workspace.id}
<MenuItem
class={twMerge(
'text-xs min-w-0 w-full overflow-hidden flex flex-col py-1.5',
'text-xs min-w-0 w-full overflow-hidden flex flex-col py-2 px-3',
workspace.disabled && 'opacity-50 cursor-not-allowed',
$workspaceStore === workspace.id
? 'cursor-default bg-surface-selected'
isSelected
? 'cursor-default bg-surface-accent-selected'
: workspace.disabled
? ''
: 'cursor-pointer hover:bg-surface-hover data-[highlighted]:bg-surface-hover'
@@ -154,73 +163,47 @@
{item}
>
<div class="flex items-center justify-between min-w-0 w-full">
<div
class={twMerge('flex items-center gap-2 min-w-0', 'pl-4')}
style:padding-left={`${4 + depth * 12}px`}
>
{#if isForked}
<GitFork size={12} class="text-primary flex-shrink-0" />
{:else}
<Building size={12} />
{/if}
<div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}>
{@render workspaceIcon(workspace.color, isForked, parentName)}
<div class="min-w-0 flex-1">
<div
class={twMerge(
'truncate text-left text-[1.2em]',
isForked ? 'text-secondary' : 'text-primary'
'truncate text-left text-xs font-normal',
isSelected ? 'text-accent' : 'text-primary'
)}
title={workspace.name}
>
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
</div>
<div
class={twMerge(
'font-mono text-2xs whitespace-nowrap truncate text-left',
isForked ? 'text-primary opacity-75' : 'text-primary'
'font-mono text-2xs whitespace-nowrap truncate text-left font-normal',
isSelected ? 'text-accent/80' : 'text-hint'
)}
title={workspace.id}
>
{workspace.id}
</div>
{#if isForked && parentName}
<div class="text-primary text-2xs truncate text-left pl-2 min-h-[1rem]">
Fork of {parentName}
</div>
{/if}
</div>
</div>
{#if workspace.color}
<div
class="w-5 h-5 mr-2 rounded border border-gray-300 dark:border-gray-600"
style="background-color: {workspace.color}"
></div>
{/if}
</div>
</MenuItem>
{/each}
</div>
{#if (isCloudHosted() || $superadmin) && !strictWorkspaceSelect}
<div class="py-1" role="none">
<a
href="{base}/user/create_workspace"
class="text-primary px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2"
role="menuitem"
tabindex="-1"
>
<MenuItem href="{base}/user/create_workspace" class={itemClass} {item}>
<Plus size={16} />
Workspace
</a>
</MenuItem>
</div>
{/if}
{#if !strictWorkspaceSelect && !isCloudHosted()}
<div class="py-1" role="none">
<a
href="{base}/user/fork_workspace"
class="text-primary px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2"
role="menuitem"
tabindex="-1"
>
<MenuItem href="{base}/user/fork_workspace" class={itemClass} {item}>
<GitFork size={16} />
Fork current workspace
</a>
</MenuItem>
</div>
{/if}
{#if !strictWorkspaceSelect}
@@ -228,10 +211,7 @@
<MenuItem
href="{base}/user/workspaces"
onClick={() => 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}
<div class="py-1" role="none">
<MenuItem
href="{base}/workspace_settings"
class={twMerge(
'text-secondary px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
<MenuItem href="{base}/workspace_settings" class={itemClass} {item}>
<Settings size={16} />
Workspace settings
</MenuItem>

View File

@@ -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<T>(o: T & object): T {
return Object.keys(o)
.sort()

View File

@@ -161,18 +161,20 @@
{/if}
<CenteredModal title="Select a workspace" subtitle="Logged in as {$usersWorkspaceStore?.email}">
<h2 class="mb-4 inline-flex gap-2 text-sm font-semibold text-emphasis">
Workspaces{#if loading}<WindmillIcon spin="fast" />{/if}
</h2>
<div class="flex flex-row items-center gap-2 justify-between">
<h2 class="mb-4 inline-flex gap-2 text-sm font-semibold text-emphasis">
Workspaces{#if loading}<WindmillIcon spin="fast" />{/if}
</h2>
{#if $superadmin}
<div class="flex flex-row-reverse pb-2">
<Toggle
bind:checked={list_all_as_super_admin}
options={{ right: 'List all workspaces as superadmin' }}
/>
</div>
{/if}
{#if $superadmin}
<div class="flex flex-row-reverse pb-2">
<Toggle
bind:checked={list_all_as_super_admin}
options={{ right: 'List all workspaces as superadmin' }}
/>
</div>
{/if}
</div>
{#if adminsInstance}
<Button
@@ -201,7 +203,7 @@
workspace.
</p>
{/if}
{#each groupedNonAdminWorkspaces as { workspace, depth, isForked, parentName } (workspace.id)}
{#each groupedNonAdminWorkspaces as { workspace, depth, isForked } (workspace.id)}
<label class="block pb-2" style:padding-left={`${depth * 24}px`}>
<Button
variant="default"
@@ -216,14 +218,14 @@
{#if isForked}
<GitFork size={12} class="text-primary mr-2 flex-shrink-0" />
{/if}
<span class="flex-1">
<span class="flex-1 items-center">
{#if workspace.color}
<span
class="inline-block w-3 h-3 mr-2 rounded-full border border-gray-400"
<div
class="inline-block w-4 h-4 mr-2 rounded-full border"
style="background-color: {workspace.color}"
></span>
></div>
{/if}
<span class="font-mono" class:text-secondary={isForked}>{workspace.id}</span> -
<span class="font-mono text-secondary">{workspace.id}</span> -
<span class:text-secondary={isForked}>{workspace.name}</span>
as
<span class="font-mono" class:text-secondary={isForked}>{workspace.username}</span>
@@ -233,12 +235,6 @@
{#if workspace.disabled}
<span class="text-red-500"> (user disabled in this workspace)</span>
{/if}
{#if isForked && parentName}
<span class="text-secondary text-2xs mt-1">
<br />
Fork of {parentName}
</span>
{/if}
</span>
</Button>
{#if $superadmin && workspace['deleted']}