feat(gui-v2): port WorkspaceSwitcher to TypeScript

Ports crewli-starter WorkspaceSwitcher into the Crewli SPA as production
TypeScript: PrimeVue Popover replaces the manual click-outside listener,
data is derived from useAuthStore/useOrganisationStore (no new store), gradient
pairs are deterministic via a new pure util with full Vitest coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 19:48:50 +02:00
parent 8444ea7443
commit 3720e8c3d3
3 changed files with 360 additions and 0 deletions

View File

@@ -0,0 +1,256 @@
<script setup lang="ts">
/**
* WorkspaceSwitcher — ported from crewli-starter WorkspaceSwitcher.vue.
*
* Data: read-only from useAuthStore (organisations, currentOrganisation,
* setActiveOrganisation). This component owns NO org state — RFC AD-G4.
*
* Popover: PrimeVue <Popover> replaces the manual document.mousedown
* click-outside listener from crewli-starter. Toggle via popoverRef.toggle($event).
*
* Icons: Crewli Icon.vue convention — name="tabler-x" :size="N".
*
* Styling: crewli-starter CSS selectors translated to Tailwind utilities inline.
* One <style scoped> block covers the two exceptions documented below.
*/
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import Icon from '@/components/Icon.vue'
import { useAuthStore } from '@/stores/useAuthStore'
import { computeOrgGradient } from '@/utils/v2/gradient'
import type { Organisation } from '@/types/auth'
defineProps<{
/**
* When true (collapsed sidebar), hide the name/sub meta text and
* show only the logo square — mirrors crewli-starter's collapsed prop.
*/
collapsed?: boolean
}>()
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
const authStore = useAuthStore()
// ---------------------------------------------------------------------------
// Derived current-workspace display object
// ---------------------------------------------------------------------------
interface WorkspaceDisplay {
id: string
initials: string
name: string
/** The role string is the relevant context identifier in Crewli. */
sub: string
gradient: [string, string]
}
function buildDisplay(org: Organisation): WorkspaceDisplay {
const words = org.name.trim().split(/\s+/)
const initials
= words.length >= 2
? (words[0][0] + words[1][0]).toUpperCase()
: org.name.slice(0, 2).toUpperCase()
return {
id: org.id,
initials,
name: org.name,
sub: org.role,
gradient: computeOrgGradient(org.id),
}
}
const current = computed<WorkspaceDisplay | null>(() => {
const org = authStore.currentOrganisation
return org ? buildDisplay(org) : null
})
// Sorted list: active org first, then the rest alphabetically
const allOrgs = computed<WorkspaceDisplay[]>(() => {
const currentId = authStore.currentOrganisation?.id
return [...authStore.organisations]
.sort((a, b) => {
if (a.id === currentId)
return -1
if (b.id === currentId)
return 1
return a.name.localeCompare(b.name)
})
.map(buildDisplay)
})
// ---------------------------------------------------------------------------
// Popover plumbing — PrimeVue Popover replaces the manual mousedown listener
// ---------------------------------------------------------------------------
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
function toggle(event: MouseEvent): void {
popoverRef.value?.toggle(event)
}
function selectOrg(ws: WorkspaceDisplay): void {
if (ws.id === authStore.currentOrganisation?.id) {
popoverRef.value?.hide()
return
}
authStore.setActiveOrganisation(ws.id)
popoverRef.value?.hide()
}
</script>
<template>
<div class="relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-[10px]">
<!-- Trigger button -->
<!-- .ws-switcher .trigger: flex, items-center, gap, w-full, px/py, rounded, border, bg-transparent, color, transition -->
<button
class="flex w-full items-center gap-[10px] rounded-[var(--p-border-radius)] border border-transparent bg-transparent px-[10px] py-[8px] text-[var(--p-text-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)]"
:class="[
collapsed ? 'justify-center' : '',
]"
aria-haspopup="true"
@click="toggle"
>
<!-- Logo square (gradient background is bespoke: dynamic hex pair cannot be a static Tailwind class RFC §7.4 justified inline-style) -->
<span
v-if="current"
class="ws-logo-square flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[12px]"
:style="{ background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})` }"
>
{{ current.initials }}
</span>
<!-- Meta: name + sub (hidden in collapsed mode) -->
<!-- .ws-switcher .meta: flex-1, min-w-0, flex-col, line-height, text-left -->
<span
v-if="!collapsed && current"
class="flex flex-1 min-w-0 flex-col text-left leading-[1.2]"
>
<!-- .ws-switcher .meta .name: text-[13.5px], font-semibold, truncate -->
<span class="truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
{{ current.name }}
</span>
<!-- .ws-switcher .meta .sub: text-[11.5px], muted, truncate -->
<span class="truncate text-[11.5px] text-[var(--p-text-muted-color)]">
{{ current.sub }}
</span>
</span>
<!-- Chevron (.ws-switcher .chev: color fg-subtle, flex-shrink-0) -->
<Icon
v-if="!collapsed"
name="tabler-chevron-down"
:size="14"
class="flex-shrink-0 text-[var(--p-text-muted-color)]"
/>
</button>
<!-- PrimeVue Popover replaces crewli-starter's manual document.mousedown click-outside -->
<Popover ref="popoverRef">
<!-- popover-head: px-[16px] py-[14px], border-bottom, flex, items-center, justify-between -->
<div class="flex items-center justify-between border-b border-[var(--p-content-border-color)] px-[16px] py-[14px]">
<!-- .popover-head .title -->
<span class="text-[15px] font-bold tracking-[-0.01em]">Workspaces</span>
<!-- .popover-head .link — TODO TECH-WS-GUI-REDESIGN: no manage-workspaces route yet -->
<span class="cursor-pointer text-[13px] font-medium text-[var(--p-primary-color)]">Manage</span>
</div>
<!-- .pop-ws .list: p-[6px] -->
<div class="min-w-[280px] p-[6px]">
<!-- .pop-ws .opt: grid 3-col, gap, p, rounded, cursor-pointer -->
<div
v-for="ws in allOrgs"
:key="ws.id"
class="grid cursor-pointer items-center gap-[12px] rounded-[var(--p-border-radius)] p-[10px] hover:bg-[var(--p-content-hover-background)]"
:class="[
ws.id === current?.id ? 'bg-[var(--p-primary-50)]' : '',
]"
style="grid-template-columns: 36px 1fr auto;"
@click="selectOrg(ws)"
>
<!-- Org logo — larger variant (36px) with dynamic gradient (same inline-style justification as trigger) -->
<span
class="ws-logo-square-lg flex-shrink-0 rounded-[var(--p-border-radius)] inline-flex items-center justify-center text-white font-bold text-[13px]"
:style="{ background: `linear-gradient(135deg, ${ws.gradient[0]}, ${ws.gradient[1]})` }"
>{{ ws.initials }}</span>
<!-- Name + sub stack -->
<span>
<!-- .pop-ws .opt .name -->
<div class="text-[14px] font-semibold text-[var(--p-text-color)]">{{ ws.name }}</div>
<!-- .pop-ws .opt .sub -->
<div class="mt-[2px] text-[12.5px] text-[var(--p-text-muted-color)]">{{ ws.sub }}</div>
</span>
<!-- Check mark for active org (.pop-ws .opt .check-mark) -->
<Icon
v-if="ws.id === current?.id"
name="tabler-check"
:size="18"
class="text-[var(--p-primary-color)]"
/>
<!-- Spacer when not current (keeps grid alignment) -->
<span
v-else
class="w-[18px]"
/>
</div>
</div>
<!-- Footer (.pop-ws .foot: p-[8px], border-top, flex, gap-[4px]) -->
<div class="flex gap-[4px] border-t border-[var(--p-content-border-color)] p-[8px]">
<!-- TODO TECH-WS-GUI-REDESIGN: create-workspace route not yet defined -->
<button class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]">
<Icon
name="tabler-plus"
:size="14"
/>
New workspace
</button>
<!-- TODO TECH-WS-GUI-REDESIGN: invite route not yet defined -->
<button class="inline-flex flex-1 h-[36px] items-center justify-center gap-[6px] rounded-[var(--p-border-radius)] border-0 bg-transparent text-[13px] font-medium text-[var(--p-text-color)] hover:bg-[var(--p-content-hover-background)]">
<Icon
name="tabler-user-plus"
:size="14"
/>
Invite
</button>
</div>
</Popover>
</div>
</template>
<style scoped>
/**
* Two layout exceptions that Tailwind cannot express as static utilities:
*
* 1. ws-logo-square: fixed 32×32px with inset box-shadow.
* Tailwind has w-8/h-8 but no built-in `box-shadow: inset 0 -2px 0 rgba(0,0,0,0.10)`.
* The background gradient is already handled via inline :style (dynamic hex pair).
*/
.ws-logo-square {
width: 32px;
height: 32px;
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
}
/**
* 2. ws-logo-square-lg: larger 36×36px variant used in the dropdown list.
* Same box-shadow exception as above (inset shadow has no Tailwind equivalent).
* Background gradient set via inline :style on the element (dynamic hex pair).
*/
.ws-logo-square-lg {
width: 36px;
height: 36px;
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
}
</style>

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { GRADIENT_PALETTE, computeOrgGradient } from '../gradient'
/**
* TDD spec for computeOrgGradient.
* Written before the implementation — expected to fail until gradient.ts is created.
*/
describe('computeOrgGradient', () => {
it('is deterministic: same id always returns the identical pair', () => {
const id = '01jv1a2b3c4d5e6f7g8h9i0j'
const first = computeOrgGradient(id)
const second = computeOrgGradient(id)
expect(first).toEqual(second)
})
it('returns a 2-tuple [string, string]', () => {
const pair = computeOrgGradient('some-org-id')
expect(pair).toHaveLength(2)
expect(typeof pair[0]).toBe('string')
expect(typeof pair[1]).toBe('string')
})
it('returns hex strings (starts with # and has 7 chars)', () => {
const [a, b] = computeOrgGradient('abc')
expect(a).toMatch(/^#[0-9a-f]{6}$/i)
expect(b).toMatch(/^#[0-9a-f]{6}$/i)
})
it('returned colors are drawn from the declared palette', () => {
const flatPalette = GRADIENT_PALETTE.flat()
const [a, b] = computeOrgGradient('test-id')
expect(flatPalette).toContain(a)
expect(flatPalette).toContain(b)
})
it('different ids generally yield different gradient pairs', () => {
// Test a sample of 5 representative ids — at least 2 distinct pairs expected
const ids = ['org-1', 'org-2', 'org-3', 'org-4', 'org-5']
const pairs = ids.map(id => computeOrgGradient(id).join(','))
const unique = new Set(pairs)
expect(unique.size).toBeGreaterThan(1)
})
it('handles empty string without throwing', () => {
expect(() => computeOrgGradient('')).not.toThrow()
const pair = computeOrgGradient('')
expect(pair).toHaveLength(2)
})
it('the returned pair consists of the two values at the chosen palette entry', () => {
// Verify internal structure: both values come from the same palette entry
const id = 'crewli-org-ulid'
const [a, b] = computeOrgGradient(id)
const matchingEntry = GRADIENT_PALETTE.find(([p0, p1]) => p0 === a && p1 === b)
expect(matchingEntry).toBeDefined()
})
})

View File

@@ -0,0 +1,39 @@
/**
* computeOrgGradient — deterministic gradient pair for an organisation.
*
* Maps an org `id` string to one of 8 teal-adjacent colour pairs via a
* simple djb2-style character-code hash. Same id always returns the
* same pair; no external dependencies.
*
* Palette rationale: teal / cyan / emerald / sea-green family to stay
* on-brand with Crewli's teal primary. Each tuple is [from, to] for a
* 135° linear gradient (darker "to" keeps depth on the logo square).
*/
/** Exported so tests can assert membership without hard-coding values. */
export const GRADIENT_PALETTE: [string, string][] = [
['#0d9488', '#0f766e'], // teal-600 → teal-700
['#0891b2', '#0e7490'], // cyan-600 → cyan-700
['#059669', '#047857'], // emerald-600 → emerald-700
['#10b981', '#059669'], // emerald-500 → emerald-600
['#0284c7', '#0369a1'], // sky-600 → sky-700
['#14b8a6', '#0d9488'], // teal-500 → teal-600
['#06b6d4', '#0891b2'], // cyan-500 → cyan-600
['#34d399', '#10b981'], // emerald-400 → emerald-500
]
/**
* Returns a `[fromHex, toHex]` colour pair deterministically derived
* from `id`. Handles empty string (hash stays 0; maps to palette[0]).
*/
export function computeOrgGradient(id: string): [string, string] {
// djb2-style hash: accumulate across char codes
let hash = 5381
for (let i = 0; i < id.length; i++) {
// Equivalent to hash * 33 ^ charCode, kept 32-bit safe via >>> 0
hash = ((hash << 5) + hash + id.charCodeAt(i)) >>> 0
}
const index = hash % GRADIENT_PALETTE.length
return GRADIENT_PALETTE[index]
}